83a2d8c963
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.
451 lines
12 KiB
Lua
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
|