Files
slate32/test/bletemp.lua
T
evan 83a2d8c963 feat(bletemp): decode Govee and BTHome sensors, one card per device
atc.lua becomes sensors.lua: a shared AD-structure walker now feeds three
decoders -- ATC1441/pvvx on service data 0x181A, Govee H5075's packed 24-bit
manufacturer data 0xEC88, and BTHome v2 on 0xFCD2, which is what pvvx firmware
advertises by default. Both new formats were decoded from adverts captured off
the air and are asserted from those bytes.

Sensors are remembered once seen, so one that stops advertising ages in place
instead of vanishing, and the list only grows. Every field is its own node --
name, temperature, humidity, battery, age -- so the one second tick repaints
the fields that moved rather than rebuilding: a rebuild is left only for a
newly discovered sensor, which has no node to dirty. Each sensor is a card.

The emulator has no Bluetooth controller, so the skill now says to keep BLE off
it entirely rather than chase a panic.
2026-08-07 10:08:55 -04:00

137 lines
5.7 KiB
Lua

-- Run: lua test/bletemp.lua
package.path = "sdcard/.lua/apps/BLETemp/?.lua;sdcard/.lua/lib/?.lua;"
.. "lib/esp32-lua-api/lua/lib/?.lua;test/?.lua;" .. package.path
local sensors = require "sensors"
-- ATC1441: 13-byte service data, big-endian. temp 23.4C, humidity 55%, batt 90%.
local atc1441 = string.char(0x10, 0x16, 0x1A, 0x18)
.. string.char(0, 1, 2, 3, 4, 5) -- MAC
.. string.char(0x00, 0xEA) -- temp 234 = 23.4C
.. string.char(0x37) -- humidity 55
.. string.char(0x5A) -- battery 90
.. string.char(0x0B, 0x54, 0x01) -- mV, counter
local reading = sensors.parse(atc1441)
assert(reading, "atc1441 parsed nil")
assert(reading.tempC == 23.4, reading.tempC)
assert(reading.humidity == 55, reading.humidity)
assert(reading.battery == 90, reading.battery)
-- pvvx: 15-byte service data, little-endian. temp 21.55C, humidity 48.20%.
local pvvx = string.char(0x12, 0x16, 0x1A, 0x18)
.. string.char(0, 1, 2, 3, 4, 5) -- MAC
.. string.char(0x73, 0x08) -- temp 2163 = 21.63C
.. string.char(0xD4, 0x12) -- humidity 4820 = 48.20%
.. string.char(0x54, 0x0B) -- mV
.. string.char(0x5A) -- battery 90
.. string.char(0x01, 0x00) -- counter, flags
reading = sensors.parse(pvvx)
assert(reading, "pvvx parsed nil")
assert(reading.tempC == 21.63, reading.tempC)
assert(reading.humidity == 48.2, reading.humidity)
assert(reading.battery == 90, reading.battery)
-- Negative temperature, big-endian two's complement: -1.0C = 0xFFF6.
local cold = string.char(0x10, 0x16, 0x1A, 0x18)
.. string.char(0, 1, 2, 3, 4, 5)
.. string.char(0xFF, 0xF6) .. string.char(0x37, 0x5A, 0x0B, 0x54, 0x01)
assert(sensors.parse(cold).tempC == -1.0, sensors.parse(cold).tempC)
-- Govee H5075, captured off the air: 22.7C, 64.5% RH, 60% battery.
local govee = string.char(0x09, 0xFF, 0x88, 0xEC)
.. string.char(0x00, 0x03, 0x79, 0x3D) -- packed 227645 = temp*1000 + hum*10
.. string.char(0x3C, 0x00) -- battery, trailing pad
reading = sensors.parse(govee)
assert(reading, "govee parsed nil")
assert(reading.tempC == 22.7, reading.tempC)
assert(reading.humidity == 64.5, reading.humidity)
assert(reading.battery == 60, reading.battery)
-- Govee negative temperature: bit 23 of the packed value is the sign.
local goveeCold = string.char(0x09, 0xFF, 0x88, 0xEC)
.. string.char(0x00, 0x80, 0x27, 0x22) -- 0x800000 | 10018 = -1.0C, 1.8% RH
.. string.char(0x3C, 0x00)
assert(sensors.parse(goveeCold).tempC == -1.0, sensors.parse(goveeCold).tempC)
-- BTHome v2 (pvvx default), captured off the air: 22.2C, 62.77% RH, 31% batt.
local bthome = string.char(0x0E, 0x16, 0xD2, 0xFC)
.. string.char(0x40) -- device info: v2, unencrypted
.. string.char(0x00, 0x5B) -- packet id
.. string.char(0x01, 0x1F) -- battery 31%
.. string.char(0x02, 0xAC, 0x08) -- temp 2220 = 22.20C
.. string.char(0x03, 0x85, 0x18) -- humidity 6277 = 62.77%
reading = sensors.parse(bthome)
assert(reading, "bthome parsed nil")
assert(reading.tempC == 22.2, reading.tempC)
assert(reading.humidity == 62.77, reading.humidity)
assert(reading.battery == 31, reading.battery)
-- Encrypted BTHome cannot be read, and an unknown object id stops the walk
-- before the temperature that follows it.
assert(sensors.parse(string.char(0x07, 0x16, 0xD2, 0xFC, 0x41, 0x02, 0xAC, 0x08)) == nil)
assert(sensors.parse(string.char(0x08, 0x16, 0xD2, 0xFC, 0x40, 0xF9, 0x00, 0x02, 0xAC)) == nil)
-- Neither 0x181A service data nor Govee manufacturer data.
assert(sensors.parse(string.char(0x02, 0x01, 0x06)) == nil)
-- App renders a parsed sensor and starts observing the 0x181A service.
local device = require "fake_device"
device.bleDevices = {
{ name = "Living Room", address = "aa:bb", rssi = -55, payload = atc1441 },
{ name = "GVH5075_CB50", address = "ee:ff", rssi = -64, payload = govee },
{ name = "TH_NURSERY", address = "11:22", rssi = -88, payload = bthome },
{ name = "Junk", address = "cc:dd", rssi = -70, payload = string.char(0x02, 0x01, 0x06) },
}
device.install()
device.start "sdcard/.lua/apps/BLETemp/main.lua"
assert(device.bleObserving)
assert(device.bleFilter.services[1] == "181A")
assert(device.bleFilter.manufacturers[1] == 0xEC88)
assert(device.labelled "Living Room")
assert(device.labelled "23.4 C")
assert(device.labelled "GVH5075_CB50")
assert(device.labelled "22.7 C")
assert(device.labelled "64.5% RH")
assert(device.labelled "60% batt")
assert(device.labelled "0s ago")
assert(device.labelled "TH_NURSERY")
assert(device.labelled "22.2 C")
assert(not device.labelled "Junk") -- no 0x181A service data, so it is not a sensor
-- A new reading from a known sensor updates that row alone; a new sensor is a
-- new row, so the screen is rebuilt.
local built = tree.getCount()
device.bleDevices[1].payload = cold
device.invalidated = {}
device.fireTimers()
assert(#device.invalidated == 1, #device.invalidated .. " nodes repainted, want 1")
assert(tree.getCount() == built, "a reading update rebuilt the screen")
assert(device.labelled "-1.0 C")
device.bleDevices[#device.bleDevices + 1] =
{ name = "Bedroom", address = "99:88", rssi = -60, payload = pvvx }
device.fireTimers()
assert(device.labelled "Bedroom", "a new sensor did not appear")
assert(tree.getCount() > built, "a new sensor did not rebuild the screen")
-- A sensor that stops advertising keeps its row and ages in place: still one
-- node repainted, no rebuild.
built = tree.getCount()
device.bleDevices = {}
device.now = device.now + 125000
device.invalidated = {}
device.fireTimers()
assert(tree.getCount() == built, "a departed sensor rebuilt the screen")
assert(#device.invalidated == 4, #device.invalidated .. " nodes repainted, want 4 age fields")
assert(device.labelled "TH_NURSERY", "a departed sensor lost its row")
assert(device.labelled "22.2 C")
assert(device.labelled "62.8% RH")
assert(device.labelled "2m ago")
print "ok"