Files
slate32/test/fake_device.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

451 lines
12 KiB
Lua

-- Stand-in for the firmware bindings, so host tests exercise the real ui.lua.
--
-- One definition of the binding surface: when a binding is added or renamed, this is
-- the file that changes. Tests mutate the returned table to steer the device, and may
-- overwrite any stub function outright for a single assertion.
local device = {
-- Text metrics chosen to match the firmware's default GLCD font, since layout
-- assertions are written in real pixels.
fontHeight = 8,
charWidth = 6,
now = 0,
rotation = 0,
theme = "light",
clockSynced = false,
tickInterval = 0,
textSize = 1,
timezone = "UTC0",
raw = nil, -- pending raw touch reading, {x, y} or nil
networks = {}, -- what wifi.scan() returns
bleDevices = {}, -- what ble.observed() returns
status = { state = "disconnected", ssid = "", ip = "", rssi = 0 },
painted = {}, -- every drawText call, in order
calibration = nil, -- last touch.setCalibration()
freeHeap = 200000,
totalHeap = 320000,
largestBlock = 100000,
connected = nil, -- last wifi.connect()
started = nil, -- last sys.startApp(), {path, args}
saveFails = false, -- make every persisting call report failure
}
local function saved()
return not device.saveFails
end
function device.install()
device.painted = {}
device.lines = {}
device.roundRects = {}
device.files = device.files or {}
device.timers = {}
device.nextTimer = 1
-- The widget tree lives in the firmware. Geometry -- layout, hit testing, style
-- inheritance -- is asserted in test/ui_layout_test.cpp against the same C++ the panel
-- runs, so this fake deliberately implements none of it. What it does keep is the
-- structure an app builds, which is what app logic is actually about: a test taps the
-- control labelled "Rotation" rather than the pixel it happened to land on.
local nodes = {}
device.nodes = nodes
device.drawn = 0
device.invalidated = {}
device.tapTarget = nil
tree = setmetatable({
reset = function()
for index in ipairs(nodes) do
nodes[index] = nil
end
end,
create = function(parent, spec)
nodes[#nodes + 1] = {
parent = parent,
label = spec.label,
kind = spec.type,
interactive = spec.interactive or spec.capture,
pressed = false,
}
return #nodes
end,
setPressed = function(id, pressed)
nodes[id].pressed = pressed and true or false
end,
isPressed = function(id)
return nodes[id].pressed
end,
attach = function(parent, child)
nodes[child].parent = parent
end,
getParent = function(id)
return nodes[id].parent
end,
getLabel = function(id)
return nodes[id].label
end,
setLabel = function(id, text)
nodes[id].label = text
end,
getCount = function()
return #nodes
end,
layout = function()
return true
end,
-- Any point is inside, because which node a point covers is geometry and geometry is
-- not this file's business. Tests choose the target with device.tap().
getRect = function()
return 0, 0, 10000, 10000
end,
hit = function()
return device.tapTarget
end,
draw = function()
device.drawn = device.drawn + 1
end,
-- Which nodes were asked to repaint, so a test can assert that a tick touched the
-- clock and nothing else.
invalidate = function(id)
device.invalidated[#device.invalidated + 1] = id
end,
}, {
__index = function()
return function() end
end,
})
screen = {
-- Font roles are scales of the one built-in font, matching the firmware's GuiProvider.
FONT_SMALL = 1,
FONT_UI = 2,
FONT_BODY = 2,
FONT_LARGE = 3,
STYLE_NORMAL = 0,
STYLE_BOLD = 0,
color = function(r, g, b)
return r * 65536 + g * 256 + b
end,
-- Rotation swaps the frame, exactly as TFT_eSPI reports it.
getWidth = function()
return device.rotation % 180 == 0 and 320 or 480
end,
getHeight = function()
return device.rotation % 180 == 0 and 480 or 320
end,
clear = function() end,
fillRect = function() end,
drawRect = function() end,
fillCircle = function() end,
drawLine = function(...)
device.lines[#device.lines + 1] = { ... }
end,
roundRect = function(...)
device.roundRects[#device.roundRects + 1] = { ... }
end,
drawPixel = function() end,
drawCircle = function() end,
fillPolygon = function() end,
drawBmp = function()
return nil, "no decoder"
end,
drawText = function(font, x, y, label)
device.painted[#device.painted + 1] = { label = label, x = x, y = y, font = font }
end,
getFontHeight = function(font)
return device.fontHeight * (font or 1)
end,
getTextWidth = function(font, text)
return #text * device.charWidth * (font or 1)
end,
setRotation = function(degrees)
if degrees % 90 ~= 0 or degrees < 0 or degrees > 270 then
return false
end
device.rotation = degrees
return saved()
end,
getRotation = function()
return device.rotation
end,
getTheme = function()
return device.theme
end,
setTheme = function(name)
device.theme = name
return saved()
end,
}
sys = {
getMillis = function()
return device.now
end,
getAPIVersion = function()
return 1
end,
hasFeature = function(name)
return name == "touch" or name == "screen"
end,
getMemory = function()
return device.freeHeap, device.totalHeap, device.largestBlock
end,
isClockSynced = function()
return device.clockSynced
end,
getTimezone = function()
return device.timezone
end,
setTimezone = function(tz)
device.timezone = tz
return saved()
end,
startApp = function(path, args)
device.started = { path = path, args = args }
end,
}
touch = {
isTouched = function()
return device.raw ~= nil
end,
getPoint = function()
if not device.raw then
return nil
end
return device.raw[1], device.raw[2]
end,
getRawPoint = function()
if not device.raw then
return nil
end
return device.raw[1], device.raw[2]
end,
setCalibration = function(...)
device.calibration = { ... }
return saved()
end,
}
fs = {
MAX_READ_BYTES = 65536,
readFile = function(path)
return device.files[path]
end,
writeFile = function(path, value)
if device.saveFails then
return nil, "read only"
end
device.files[path] = value
return true
end,
exists = function(path)
return device.files[path] ~= nil
end,
listFiles = function()
return {}
end,
listDirs = function()
return {}
end,
fileSize = function(path)
local value = device.files[path]
return value and #value or nil, value and nil or "missing"
end,
}
-- Timers fire only when a test asks, so a callback's effect is asserted where it happens.
timer = {
after = function(ms, callback)
return device.addTimer(ms, callback, false)
end,
every = function(ms, callback)
return device.addTimer(ms, callback, true)
end,
cancel = function(id)
local found = device.timers[id] ~= nil
device.timers[id] = nil
return found
end,
}
wifi = {
scan = function()
return device.networks
end,
getStatus = function()
return device.status
end,
connect = function(ssid, password)
device.wifiReconnect = ssid == nil
device.connected = { ssid, password }
if ssid then
device.status.ssid = ssid
end
device.status.state = "connecting"
return saved()
end,
disconnect = function()
device.status.state = "disconnected"
end,
forget = function()
device.connected = nil
device.status = { state = "disconnected", ssid = "", ip = "", rssi = 0 }
return saved()
end,
}
ble = {
init = function(name)
device.bleInitialized, device.bleName = true, name
return true
end,
deinit = function()
device.bleInitialized = false
device.bleName, device.bleConnected, device.bleAdvertising = nil, nil, nil
end,
isInitialized = function()
return device.bleInitialized == true
end,
observe = function(filter)
device.bleObserving = true
device.bleFilter = filter
return true
end,
unobserve = function()
device.bleObserving = false
end,
isObserving = function()
return device.bleObserving == true
end,
-- The firmware stamps every sighting, so a fixture that does not care about
-- staleness still gets the fresh timestamp it would see on the panel.
observed = function()
for _, entry in ipairs(device.bleDevices) do
entry.lastSeenMs = entry.lastSeenMs or device.now
end
return device.bleDevices
end,
connect = function(address)
device.bleConnected = address
return true
end,
disconnect = function()
device.bleConnected = nil
end,
isConnected = function()
return device.bleConnected ~= nil
end,
read = function()
return "test"
end,
write = function(service, characteristic, value)
device.bleWrite = { service, characteristic, value }
return true
end,
startAdvertising = function(name)
device.bleAdvertising = name
return true
end,
stopAdvertising = function()
device.bleAdvertising = nil
end,
}
log = { debug = function() end, info = function() end, error = function() end }
return device
end
-- The first node whose text starts with `prefix`, whether or not anything can press it.
function device.labelled(prefix)
for id, entry in ipairs(device.nodes) do
if entry.label and entry.label:sub(1, #prefix) == prefix then
return id
end
end
end
-- The nearest ancestor of that text that can be pressed: a card's label is a child of the
-- button, and it is the button a finger lands on.
function device.find(prefix)
for id, entry in ipairs(device.nodes) do
if entry.label and entry.label:sub(1, #prefix) == prefix then
local target = id
while target and not device.nodes[target].interactive do
target = device.nodes[target].parent
end
if target then
return target
end
end
end
end
-- Presses the control labelled `prefix`, the way a test means it: by what it says, not by
-- where it landed.
-- Registers a timer and hands back its id, mirroring timer.after/every.
function device.addTimer(intervalMs, callback, repeating)
local id = device.nextTimer
device.nextTimer = id + 1
device.timers[id] = { callback = callback, intervalMs = intervalMs, repeating = repeating }
return id
end
-- Fires every registered timer once, which is what a test means by "time passed".
function device.fireTimers()
for id, entry in pairs(device.timers) do
if not entry.repeating then
device.timers[id] = nil
end
entry.callback()
end
end
function device.tap(prefix)
local target = device.find(prefix)
if not target then
error("no control labelled '" .. prefix .. "'", 2)
end
device.press(target, 0, 0)
end
-- Presses a node at a point, for the widgets that have no label to aim at. getRect()
-- answers the same box here as it does inside the widget, so a custom painter's own
-- geometry decides what was hit, exactly as it does on the panel.
function device.press(id, x, y)
local ui = require "ui"
device.tapTarget = id
ui.down(x, y)
ui.up(x, y)
device.tapTarget = nil
end
-- Loads and mounts an app the way /.lua/main.lua does on the panel, minus the chrome:
-- the bar is a node like any other and is asserted on its own.
---Mounts an app the way main.lua does, minus the bar. Navigation is reset first,
---because the firmware reaches an app through a fresh state and nothing carries over.
function device.start(path, arg)
local ui = require "ui"
local nav = require "nav"
nav.start { app = path:match "apps/(.+)/main%.lua$" or path, arg = arg }
local app = dofile(path)
device.app = app
if app.init then
app.init(arg)
end
ui.mount(app.node)
return app
end
function device.findKind(kind)
for id, entry in ipairs(device.nodes) do
if entry.kind == kind then
return id
end
end
end
return device