apps: refactor settings

This commit is contained in:
2026-08-06 14:09:36 -04:00
parent e92a8a4cc9
commit 78f2286b73
22 changed files with 759 additions and 630 deletions
+3 -1
View File
@@ -3,7 +3,9 @@
/compile_commands.json
_scratch/
# Shared Lua modules are copied in from lib/esp32-lua-api by `make sdcard`.
# Shared modules ship as stripped bytecode inside the firmware. A copy here would
# shadow it with source, which costs ~16KB of heap per live Lua state; keep them
# ignored so a debugging copy never gets committed.
sdcard/.lua/lib/ui.lua
sdcard/.lua/lib/hints.lua
/.lua/
+3 -2
View File
@@ -130,8 +130,9 @@ esp-emu --board e32r40t boot .pio/build/esp32-32e/firmware.bin --sdcard card.img
```
Apps are `/.lua/apps/<name>/main.lua` with data under `/.lua/data/`; settings persist in
`/settings.lua`. Run `make sdcard` first: the shared modules in `/.lua/lib` are copied from
the submodule and are not committed here. Directory contents overwrite matching files in the persistent state image and other image contents remain, so use `--fresh-sd` when a test needs default settings or a known app list.
`/settings.lua`, which is also how a test seeds Wi-Fi credentials or a rotation before boot.
`ui` and `hints` resolve from the firmware's embedded bytecode, so `/.lua/lib` holds only
this repo's modules. Directory contents overwrite matching files in the persistent state image and other image contents remain, so use `--fresh-sd` when a test needs default settings or a known app list.
## Lua-only Logic
+1 -9
View File
@@ -5,10 +5,9 @@ LUA ?= lua
CXX ?= c++
CXXFLAGS ?= -std=c++11 -Wall -Wextra
BUILD_DIR := .pio/build/esp32-32e
SHARED_LUA := lib/esp32-lua-api/lua/lib
LUA_TESTS := test/keyboard.lua test/settings_calibration.lua test/statusbar_dirty.lua test/settings_busy.lua test/ble.lua
.PHONY: test test-lua test-cpp test-shared sdcard compiledb build upload monitor clean format
.PHONY: test test-lua test-cpp test-shared compiledb build upload monitor clean format
# C++ only: Lua is formatted on save by lua-language-server, which reads the
# same .editorconfig, so a second Lua formatter here would only fight it.
@@ -16,13 +15,6 @@ format:
@clang-format -i $(shell find src test -name '*.cpp' -o -name '*.h')
@$(MAKE) -C lib/esp32-lua-api format
# The shared modules ship from the submodule rather than a copy in this repo, so an app
# on the card and an app on the host resolve the same ui.lua. Run before flashing or
# booting the emulator, both of which read sdcard/ directly.
sdcard:
@cp $(SHARED_LUA)/*.lua sdcard/.lua/lib/
@echo "sdcard/.lua/lib <- $(SHARED_LUA)"
# The layout engine, the shared modules and the API declarations are tested in
# lib/esp32-lua-api; what is left here is this firmware's own Lua and gfx.
test: test-cpp test-lua test-shared
+155
View File
@@ -0,0 +1,155 @@
local ui = require "ui"
local nav = require "nav"
---@class BleApp : SlateApp
local M = {}
local mode = "ble"
local bleMessage, busyText
local bleDevices = {}
local bleScanRequested, bleConnectRequested = false, false
local selectedBleDevice
local bleDeviceOf = {}
local function show(next)
mode = next
ui.rebuild()
end
local function backTo(next)
return function()
show(next)
end
end
local function busy(text)
busyText = text
show "busy"
end
local function enableBle()
local ok, err = ble.init "Slate32 BLE"
bleMessage = ok and "BLE on" or err
show "ble"
end
local function disableBle()
ble.deinit()
bleMessage = "BLE off"
show "ble"
end
local function disconnectBle()
ble.disconnect()
bleMessage = "BLE disconnected"
show "ble"
end
local function startBleAdvertising()
local ok, err = ble.startAdvertising "Slate32 BLE"
bleMessage = ok and "BLE advertising" or err
show "ble"
end
local function stopBleAdvertising()
ble.stopAdvertising()
bleMessage = "BLE advertising stopped"
show "ble"
end
local function requestBleScan()
bleScanRequested = true
busy "scanning BLE..."
end
local function selectBleDevice(id)
selectedBleDevice = bleDeviceOf[id]
bleConnectRequested = true
busy "connecting BLE..."
end
local function bleScreen()
local initialized = ble.isInitialized()
local items = { pad = 12, gap = 7, ui.text "BLE", ui.text(initialized and "on" or "off") }
if bleMessage then
items[#items + 1] = ui.text(bleMessage, { font = screen.FONT_SMALL })
end
if initialized then
items[#items + 1] = ui.button { label = "scan devices", on_click = requestBleScan }
items[#items + 1] = ui.button { label = "start advertising", on_click = startBleAdvertising }
items[#items + 1] = ui.button { label = "stop advertising", on_click = stopBleAdvertising }
if ble.isConnected() then
items[#items + 1] = ui.button { label = "disconnect", on_click = disconnectBle }
end
items[#items + 1] = ui.button { label = "turn off", on_click = disableBle }
else
items[#items + 1] = ui.button { label = "turn on", on_click = enableBle }
end
return items
end
local function bleDevicesScreen()
local items = { pad = 12, gap = 6, ui.text "BLE devices" }
bleDeviceOf = {}
for index = 1, math.min(#bleDevices, 6) do
local device = bleDevices[index]
local name = device.name ~= "" and device.name or device.address
if #name > 24 then
name = name:sub(1, 24)
end
local button = ui.button {
label = name .. " " .. device.rssi,
on_click = selectBleDevice,
}
bleDeviceOf[button] = device
items[#items + 1] = button
end
if #bleDevices == 0 then
items[#items + 1] = ui.text "no devices found"
end
items[#items + 1] = ui.button { label = "rescan", on_click = requestBleScan }
items[#items + 1] = ui.button { label = "back", on_click = backTo "ble" }
return items
end
local SCREENS = {
ble = bleScreen,
ble_devices = bleDevicesScreen,
busy = function()
return { w = "fill", h = "fill", justify = "center", align = "center", ui.label(busyText) }
end,
}
function M.node()
return ui.box { w = "fill", h = "fill", ui.box(SCREENS[mode]()) }
end
function M.init()
nav.setTitle "BLE"
timer.every(50, M.tick)
log.info "BLE ready"
end
function M.tick()
if bleScanRequested then
bleScanRequested = false
local devices, err = ble.scan(3000)
if devices then
log.info("BLE scan found " .. #devices .. " devices")
bleDevices = devices
show "ble_devices"
else
bleMessage = err
show "ble"
end
return
end
if bleConnectRequested then
bleConnectRequested = false
local ok, err = ble.connect(selectedBleDevice.address)
bleMessage = ok and "BLE connected" or err
show "ble"
end
end
return M
@@ -0,0 +1,85 @@
local ui = require "ui"
local nav = require "nav"
local statusbar = require "statusbar"
local FONT = screen.FONT_UI
local INSET = 30
---@class CalibrateApp : SlateApp
local M = {}
local samples, pending, armed = {}, nil, false
local targetIndex = 1
local rotationBeforeCalibration = 0
function M.computeCalibration(s1, s2, w, h, inset)
local sx = (s2.x - s1.x) / (w - 2 * inset)
local sy = (s2.y - s1.y) / (h - 2 * inset)
return math.floor(s1.x - sx * inset),
math.floor(s1.y - sy * inset),
math.floor(s2.x + sx * inset),
math.floor(s2.y + sy * inset)
end
local function target(n)
if n == 1 then
return INSET, INSET
end
return 320 - INSET, 480 - INSET
end
local function paintTarget(_, ox, oy)
local x, y = target(targetIndex)
local theme = ui.theme
screen.drawText(FONT, ox + 10, oy + 10, "tap the cross", theme.color, nil, theme.background)
screen.drawLine(x - 12, y, x + 12, y, theme.accent)
screen.drawLine(x, y - 12, x, y + 12, theme.accent)
end
local function finishCalibration()
touch.setCalibration(M.computeCalibration(samples[1], samples[2], 320, 480, INSET))
-- Rotation is saved as it is applied, so the upright calibration is a change this app
-- has to put back before it leaves.
screen.setRotation(rotationBeforeCalibration)
statusbar.setFullscreen(false)
nav.back()
end
function M.node()
return ui.custom { w = "fill", h = "fill", paint = paintTarget }
end
function M.init()
nav.setTitle "Calibrate"
samples, pending, armed = {}, nil, false
targetIndex = 1
rotationBeforeCalibration = screen.getRotation()
screen.setRotation(0)
statusbar.setFullscreen(true)
timer.every(50, M.tick)
log.info "Calibrate ready"
end
function M.tick()
local rx, ry = touch.getRawPoint()
if not armed then
if not rx then
armed = true
end
return
end
if rx then
pending = { x = rx, y = ry }
elseif pending then
samples[#samples + 1] = pending
pending = nil
if #samples == 1 then
targetIndex = 2
ui.rebuild()
else
finishCalibration()
end
end
end
return M
@@ -0,0 +1,18 @@
local optionlist = require "optionlist"
-- setRotation persists and re-clips the panel itself, so applying is the whole action.
return optionlist.app {
title = "Rotation",
options = {
{ label = "0 degrees", value = 0 },
{ label = "90 degrees", value = 90 },
{ label = "180 degrees", value = 180 },
{ label = "270 degrees", value = 270 },
},
current = function()
return screen.getRotation()
end,
apply = function(value)
return screen.setRotation(value)
end,
}
+16
View File
@@ -0,0 +1,16 @@
local ui = require "ui"
local optionlist = require "optionlist"
-- Apps call ui.setTheme, never screen.setTheme: it writes through, rebuilds the palette
-- and repaints, which is the one seam C cannot drive itself.
local options = {}
for _, name in ipairs(ui.themeNames()) do
options[#options + 1] = { label = name, value = name }
end
return optionlist.app {
title = "Theme",
options = options,
current = ui.getTheme,
apply = ui.setTheme,
}
@@ -0,0 +1,16 @@
local optionlist = require "optionlist"
local zones = require "timezones"
-- setTimezone applies the TZ itself, so a saved zone missing from the list just leaves
-- nothing marked rather than pretending to be the first entry.
local options = {}
for _, zone in ipairs(zones) do
options[#options + 1] = { label = zone.name, value = zone.tz }
end
return optionlist.app {
title = "Timezone",
options = options,
current = sys.getTimezone,
apply = sys.setTimezone,
}
+264
View File
@@ -0,0 +1,264 @@
local ui = require "ui"
local nav = require "nav"
---@class WifiApp : SlateApp
local M = {}
-- Every screen is a value of mode; node() builds whichever is current, and nothing is
-- retained between them. This is its own app now, so the status screen's back leaves to
-- the Settings menu (nav.back) while the deeper screens fall back to the status one.
local mode = "wifi"
local message, busyText, dialog
local networks = {}
local scanRequested, keyboardRequested = false, false
local selectedNetwork, password = nil, ""
local passwordLabel
local networkOf = {}
local function show(next)
mode = next
ui.rebuild()
end
local function backTo(next)
return function()
show(next)
end
end
local function wifiValue(status)
return status.state == "connected" and "on" or "off"
end
-- Announced rather than left to draw(): the work these stand in for blocks the loop, and
-- tick() runs before draw in the same pass, so the panel would sit on the background the
-- rebuild cleared it to until the blocking call returned.
local function busy(text)
busyText = text
show "busy"
end
local function requestScan()
scanRequested = true
busy "scanning networks..."
end
local function forgetNetwork()
dialog = nil
message = wifi.forget() and "wifi forgotten" or "save failed"
show "wifi"
end
local function dismissDialog()
dialog = nil
ui.rebuild()
end
-- Destructive and one tap away from the row above it, which is what a confirm is for.
local function confirmForget()
dialog = "forget"
show "wifi"
end
local function connectSavedNetwork()
if not wifi.connect() then
message = "could not connect wifi"
return show "wifi"
end
show "connecting"
end
local function connectSelected()
if not wifi.connect(selectedNetwork.ssid, password) then
message = "could not save wifi"
return show "wifi"
end
show "connecting"
end
local function disconnectWifi()
wifi.disconnect()
message = "wifi off"
show "wifi"
end
local function chooseNetwork(network)
selectedNetwork, password = { ssid = network.ssid }, ""
if not network.secure then
return connectSelected()
end
keyboardRequested = true
busy "opening keyboard..."
end
local function selectNetwork(id)
chooseNetwork(networkOf[id])
end
local function wifiScreen()
local status = wifi.getStatus()
local items = { pad = 12, gap = 8, ui.text "wifi", ui.text(wifiValue(status)) }
if status.state == "connected" then
items[#items + 1] = ui.text("ip: " .. status.ip)
end
if status.ssid ~= "" then
items[#items + 1] = ui.button {
label = status.state == "connected" and "disconnect" or "connect",
on_click = status.state == "connected" and disconnectWifi or connectSavedNetwork,
}
end
items[#items + 1] = ui.button { label = "scan networks", on_click = requestScan }
if status.ssid ~= "" then
items[#items + 1] = ui.button { label = "forget network", on_click = confirmForget }
end
if message then
items[#items + 1] = ui.text(message)
end
return items
end
local function connectingScreen()
local to = selectedNetwork and (" to " .. selectedNetwork.ssid) or ""
return {
pad = 12,
gap = 8,
ui.text "wifi",
ui.text("connecting" .. to .. "..."),
ui.button { label = "back", on_click = backTo "wifi" },
}
end
local function networksScreen()
local byName = {}
for _, network in ipairs(networks) do
local current = byName[network.ssid]
if network.ssid ~= "" and (not current or network.rssi > current.rssi) then
byName[network.ssid] = network
end
end
local list = {}
for _, network in pairs(byName) do
list[#list + 1] = network
end
table.sort(list, function(a, b)
return a.rssi > b.rssi
end)
local items = { pad = 12, gap = 6, ui.text "wifi networks" }
networkOf = {}
for i = 1, math.min(#list, 6) do
local network = list[i]
local lock = network.secure and " *" or ""
local button = ui.button {
label = network.ssid .. lock .. " " .. network.rssi,
on_click = selectNetwork,
}
-- The network a card stands for, keyed by node id: a node carries nothing an app
-- puts on it, so anything hung on it lives in a Lua table instead.
networkOf[button] = network
items[#items + 1] = button
end
if #list == 0 then
items[#items + 1] = ui.text("no networks found", { color = ui.theme.muted })
end
items[#items + 1] = ui.button { label = "rescan", on_click = requestScan }
items[#items + 1] = ui.button { label = "back", on_click = backTo "wifi" }
return items
end
local function passwordScreen()
local keyboard = require "keyboard"
passwordLabel = ui.text("password: " .. password, { w = "fill" })
return {
pad = 8,
gap = 5,
ui.text(selectedNetwork.ssid),
passwordLabel,
keyboard.new {
value = password,
on_change = function(value)
password = value
ui.setText(passwordLabel, "password: " .. password)
end,
on_submit = function(value)
password = value
connectSelected()
end,
},
}
end
local SCREENS = {
wifi = wifiScreen,
connecting = connectingScreen,
networks = networksScreen,
password = passwordScreen,
busy = function()
return { w = "fill", h = "fill", justify = "center", align = "center", ui.label(busyText) }
end,
}
function M.node()
-- The content box carries the padding so a dialog can cover the whole panel. The scrim
-- is a style on the box holding the content, so it reaches the margins the content does
-- not while the dialog above it keeps the lit palette.
local content = ui.box(SCREENS[mode]())
if not dialog then
return ui.box { w = "fill", h = "fill", content }
end
return ui.box {
w = "fill",
h = "fill",
ui.box { w = "fill", h = "fill", color = ui.theme.muted, face = ui.theme.background, content },
ui.confirm {
title = "forget network?",
message = wifi.getStatus().ssid,
ok = "forget",
on_ok = forgetNetwork,
on_cancel = dismissDialog,
},
}
end
function M.init()
nav.setTitle "WiFi"
timer.every(50, M.tick)
log.info "WiFi ready"
end
function M.tick()
if keyboardRequested then
keyboardRequested = false
show "password"
return
end
if scanRequested then
scanRequested = false
-- A scan that cannot start reports why: the radio needs a contiguous buffer, and a
-- crash here would take the app down over a condition the next attempt may not hit.
local found, err = wifi.scan()
if not found then
message = err or "scan failed"
log.error("wifi scan failed: " .. tostring(err))
show "wifi"
return
end
networks = found
log.info("wifi scan found " .. #networks .. " networks")
show "networks"
return
end
if mode == "connecting" then
local status = wifi.getStatus()
if status.state == "connected" then
message = "connected: " .. status.ip
show "wifi"
elseif status.state == "failed" or status.state == "not_found" then
message = "connection " .. status.state
log.info("wifi connection " .. status.state)
show "wifi"
end
end
end
return M
+14 -499
View File
@@ -1,106 +1,12 @@
local ui = require "ui"
local statusbar = require "statusbar"
local nav = require "nav"
local zones = require "timezones"
local FONT = screen.FONT_UI
local INSET = 30
local MENU_PAD, MENU_GAP = 12, 8
---@class SettingsApp : SlateApp
local M = {}
-- Every screen this app has is a value of `mode`, and node() builds whichever one is
-- current. Nothing is retained between them, so a rotation, a theme change and a tap that
-- navigates are all the same operation: set the state, rebuild.
local mode = "menu"
local message, bleMessage, busyText
local dialog
local networks, bleDevices = {}, {}
local samples, pending, armed = {}, nil, false
local scanRequested, keyboardRequested = false, false
local bleScanRequested, bleConnectRequested = false, false
local selectedNetwork, password = nil, ""
local selectedBleDevice
local targetIndex = 1
local rotationBeforeCalibration = 0
local passwordLabel
local networkOf, bleDeviceOf = {}, {}
local function show(next)
mode = next
ui.rebuild()
end
-- Two inset targets give a raw-per-pixel slope; extrapolate it to the screen edges.
function M.computeCalibration(s1, s2, w, h, inset)
local sx = (s2.x - s1.x) / (w - 2 * inset)
local sy = (s2.y - s1.y) / (h - 2 * inset)
return math.floor(s1.x - sx * inset),
math.floor(s1.y - sy * inset),
math.floor(s2.x + sx * inset),
math.floor(s2.y + sy * inset)
end
local function target(n)
if n == 1 then
return INSET, INSET
end
return 320 - INSET, 480 - INSET
end
-- The one painter in the app: a cross has no widget, and the panel underneath must stay
-- in physical coordinates while the samples are read.
local function paintTarget(_, ox, oy)
local x, y = target(targetIndex)
local theme = ui.theme
screen.drawText(FONT, ox + 10, oy + 10, "tap the cross", theme.color, nil, theme.background)
screen.drawLine(x - 12, y, x + 12, y, theme.accent)
screen.drawLine(x, y - 12, x, y + 12, theme.accent)
end
local function finishCalibration()
local ok = touch.setCalibration(M.computeCalibration(samples[1], samples[2], 320, 480, INSET))
message = ok and "calibration saved" or "save failed"
screen.setRotation(rotationBeforeCalibration)
mode = "menu"
statusbar.setFullscreen(false) -- rebuilds, which is what puts the menu back
end
-- Rotation is saved as it is applied, so calibrating upright is a change the app
-- has to put back itself once the samples are in.
local function startCalibration()
message = nil
samples, pending, armed = {}, nil, false
targetIndex = 1
mode = "calibrate"
rotationBeforeCalibration = screen.getRotation()
screen.setRotation(0)
-- The targets sit at the physical corners and the samples are read in panel
-- coordinates, so the status bar cannot be allowed to shift the frame.
statusbar.setFullscreen(true)
end
local function cycleRotation()
local ok = screen.setRotation((screen.getRotation() + 90) % 360)
message = ok and "rotation saved" or "save failed"
ui.rebuild()
end
local function cycleTheme()
local names = ui.themeNames()
local next_index = 1
for index, name in ipairs(names) do
if name == ui.getTheme() then
next_index = index % #names + 1
end
end
local ok = ui.setTheme(names[next_index])
message = ok and "theme saved" or "save failed"
ui.rebuild()
end
-- The stored value is a POSIX rule, so a zone set by hand and missing from the list
-- shows its rule rather than pretending to be the first entry.
local function zoneLabel()
local current = sys.getTimezone()
for _, zone in ipairs(zones) do
@@ -111,136 +17,20 @@ local function zoneLabel()
return current
end
local function cycleTimezone()
local current = sys.getTimezone()
local next_index = 1
for index, zone in ipairs(zones) do
if zone.tz == current then
next_index = index % #zones + 1
end
end
message = sys.setTimezone(zones[next_index].tz) and nil or "save failed"
ui.rebuild()
end
local function wifiValue(status)
return status.state == "connected" and "on" or "off"
end
-- Announced rather than left to draw(): the work these stand in for blocks the loop, and
-- tick() runs before draw in the same pass, so the panel would sit on the background the
-- rebuild cleared it to until the blocking call returned.
local function busy(text)
busyText = text
show "busy"
end
local function requestScan()
scanRequested = true
busy "scanning networks..."
end
local function forgetNetwork()
dialog = nil
message = wifi.forget() and "wifi forgotten" or "save failed"
show "wifi"
end
local function dismissDialog()
dialog = nil
ui.rebuild()
end
-- Destructive and one tap away from the row above it, which is what a confirm is for.
local function confirmForget()
dialog = "forget"
show "wifi"
end
local function connectSavedNetwork()
if not wifi.connect() then
message = "could not connect wifi"
return show "wifi"
end
show "connecting"
end
local function connectSelected()
if not wifi.connect(selectedNetwork.ssid, password) then
message = "could not save wifi"
return show "wifi"
end
show "connecting"
end
local function disconnectWifi()
wifi.disconnect()
message = "wifi off"
show "wifi"
end
local function chooseNetwork(network)
selectedNetwork, password = { ssid = network.ssid }, ""
if not network.secure then
return connectSelected()
end
keyboardRequested = true
busy "opening keyboard..."
end
local function selectNetwork(id)
chooseNetwork(networkOf[id])
end
local function enableBle()
local ok, err = ble.init "Slate32 BLE"
bleMessage = ok and "BLE on" or err
show "ble"
end
local function disableBle()
ble.deinit()
bleMessage = "BLE off"
show "ble"
end
local function disconnectBle()
ble.disconnect()
bleMessage = "BLE disconnected"
show "ble"
end
local function startBleAdvertising()
local ok, err = ble.startAdvertising "Slate32 BLE"
bleMessage = ok and "BLE advertising" or err
show "ble"
end
local function stopBleAdvertising()
ble.stopAdvertising()
bleMessage = "BLE advertising stopped"
show "ble"
end
local function requestBleScan()
bleScanRequested = true
busy "scanning BLE..."
end
local function selectBleDevice(id)
selectedBleDevice = bleDeviceOf[id]
bleConnectRequested = true
busy "connecting BLE..."
end
local function card(side, title, value, on_click)
local function card(side, title, value, route)
local spec = {
w = side,
h = side,
gap = 6,
justify = "center",
align = "center",
on_click = on_click,
on_click = function()
nav.launch(route)
end,
ui.label(title, { font = screen.FONT_UI, fit = side - 12 }),
}
if value then
@@ -249,24 +39,17 @@ local function card(side, title, value, on_click)
return ui.button(spec)
end
local function menuScreen()
-- No title: the status bar already names the running app. The status line under the
-- grid is worth its rows, so the cards give it room rather than pushing it off screen.
local side, cols = ui.cardSide(6, MENU_PAD, MENU_GAP, message and 20 or 0)
function M.node()
local side, cols = ui.cardSide(6, MENU_PAD, MENU_GAP)
local cards = {
card(side, "Calibrate", "touch", startCalibration),
card(side, "Rotation", screen.getRotation() .. " deg", cycleRotation),
card(side, "WiFi", wifiValue(wifi.getStatus()), function()
show "wifi"
end),
card(side, "BLE", ble.isInitialized() and "on" or "off", function()
show "ble"
end),
card(side, "Theme", ui.getTheme(), cycleTheme),
card(side, "Timezone", zoneLabel(), cycleTimezone),
card(side, "Calibrate", "touch", "Settings/Calibrate"),
card(side, "Rotation", screen.getRotation() .. " deg", "Settings/Rotation"),
card(side, "WiFi", wifiValue(wifi.getStatus()), "Settings/Wifi"),
card(side, "BLE", ble.isInitialized() and "on" or "off", "Settings/Ble"),
card(side, "Theme", ui.getTheme(), "Settings/Theme"),
card(side, "Timezone", zoneLabel(), "Settings/Timezone"),
}
-- Centred left to right as a unit, like the home grid, and still top aligned.
local items = { pad = MENU_PAD, gap = MENU_GAP, w = "fill", align = "center" }
local gridW = cols * side + (cols - 1) * MENU_GAP
for index = 1, #cards, cols do
@@ -276,279 +59,11 @@ local function menuScreen()
end
items[#items + 1] = ui.box(row)
end
if message then
items[#items + 1] = ui.text(message)
end
return items
end
local function backTo(next)
return function()
show(next)
end
end
local function wifiScreen()
local status = wifi.getStatus()
local items = { pad = 12, gap = 8, ui.text "wifi", ui.text(wifiValue(status)) }
if status.state == "connected" then
items[#items + 1] = ui.text("ip: " .. status.ip)
end
if status.ssid ~= "" then
items[#items + 1] = ui.button {
label = status.state == "connected" and "disconnect" or "connect",
on_click = status.state == "connected" and disconnectWifi or connectSavedNetwork,
}
end
items[#items + 1] = ui.button { label = "scan networks", on_click = requestScan }
if status.ssid ~= "" then
items[#items + 1] = ui.button { label = "forget network", on_click = confirmForget }
end
items[#items + 1] = ui.button { label = "back", on_click = backTo "menu" }
return items
end
local function connectingScreen()
local to = selectedNetwork and (" to " .. selectedNetwork.ssid) or ""
return {
pad = 12,
gap = 8,
ui.text "wifi",
ui.text("connecting" .. to .. "..."),
ui.button { label = "back", on_click = backTo "wifi" },
}
end
local function networksScreen()
local byName = {}
for _, network in ipairs(networks) do
local current = byName[network.ssid]
if network.ssid ~= "" and (not current or network.rssi > current.rssi) then
byName[network.ssid] = network
end
end
local list = {}
for _, network in pairs(byName) do
list[#list + 1] = network
end
table.sort(list, function(a, b)
return a.rssi > b.rssi
end)
local items = { pad = 12, gap = 6, ui.text "wifi networks" }
networkOf = {}
for i = 1, math.min(#list, 6) do
local network = list[i]
local lock = network.secure and " *" or ""
local button = ui.button {
label = network.ssid .. lock .. " " .. network.rssi,
on_click = selectNetwork,
}
-- The network a card stands for, keyed by node id: a node is sixteen bytes in the
-- firmware and carries nothing an app puts on it.
networkOf[button] = network
items[#items + 1] = button
end
if #list == 0 then
items[#items + 1] = ui.text("no networks found", { color = ui.theme.muted })
end
items[#items + 1] = ui.button { label = "rescan", on_click = requestScan }
items[#items + 1] = ui.button { label = "back", on_click = backTo "wifi" }
return items
end
local function bleScreen()
local initialized = ble.isInitialized()
local items = { pad = 12, gap = 7, ui.text "BLE", ui.text(initialized and "on" or "off") }
if bleMessage then
items[#items + 1] = ui.text(bleMessage, { font = screen.FONT_SMALL })
end
if initialized then
items[#items + 1] = ui.button { label = "scan devices", on_click = requestBleScan }
items[#items + 1] = ui.button { label = "start advertising", on_click = startBleAdvertising }
items[#items + 1] = ui.button { label = "stop advertising", on_click = stopBleAdvertising }
if ble.isConnected() then
items[#items + 1] = ui.button { label = "disconnect", on_click = disconnectBle }
end
items[#items + 1] = ui.button { label = "turn off", on_click = disableBle }
else
items[#items + 1] = ui.button { label = "turn on", on_click = enableBle }
end
items[#items + 1] = ui.button { label = "back", on_click = backTo "menu" }
return items
end
local function bleDevicesScreen()
local items = { pad = 12, gap = 6, ui.text "BLE devices" }
bleDeviceOf = {}
for index = 1, math.min(#bleDevices, 6) do
local device = bleDevices[index]
local name = device.name ~= "" and device.name or device.address
if #name > 24 then
name = name:sub(1, 24)
end
local button = ui.button {
label = name .. " " .. device.rssi,
on_click = selectBleDevice,
}
bleDeviceOf[button] = device
items[#items + 1] = button
end
if #bleDevices == 0 then
items[#items + 1] = ui.text "no devices found"
end
items[#items + 1] = ui.button { label = "rescan", on_click = requestBleScan }
items[#items + 1] = ui.button { label = "back", on_click = backTo "ble" }
return items
end
local function passwordScreen()
local keyboard = require "keyboard"
passwordLabel = ui.text("password: " .. password, { w = "fill" })
return {
pad = 8,
gap = 5,
ui.text(selectedNetwork.ssid),
passwordLabel,
keyboard.new {
value = password,
on_change = function(value)
password = value
ui.setText(passwordLabel, "password: " .. password)
end,
on_submit = function(value)
password = value
connectSelected()
end,
},
}
end
local SCREENS = {
menu = menuScreen,
wifi = wifiScreen,
connecting = connectingScreen,
networks = networksScreen,
ble = bleScreen,
ble_devices = bleDevicesScreen,
password = passwordScreen,
busy = function()
return { w = "fill", h = "fill", justify = "center", align = "center", ui.label(busyText) }
end,
}
function M.node()
if mode == "calibrate" then
return ui.custom { w = "fill", h = "fill", paint = paintTarget }
end
-- The content box carries the padding so a dialog can cover the whole panel. The scrim
-- is a style on the box holding the content, so it reaches the margins the content does
-- not while the dialog above it keeps the lit palette.
local content = ui.box(SCREENS[mode]())
if not dialog then
return ui.box { w = "fill", h = "fill", content }
end
return ui.box {
w = "fill",
h = "fill",
ui.box { w = "fill", h = "fill", color = ui.theme.muted, face = ui.theme.background, content },
ui.confirm {
title = "forget network?",
message = wifi.getStatus().ssid,
ok = "forget",
on_ok = forgetNetwork,
on_cancel = dismissDialog,
},
}
return ui.box(items)
end
function M.init()
timer.every(50, M.tick) -- calibration samples the raw panel between draws
log.info "settings ready"
end
function M.tick()
if keyboardRequested then
keyboardRequested = false
show "password"
local free, _, largest = sys.getMemory()
log.info("wifi keyboard ready free=" .. free .. " largest=" .. largest)
return
end
if scanRequested then
scanRequested = false
-- A scan that cannot start reports why: the radio needs a contiguous buffer, and a
-- crash here would take the app down over a condition the next attempt may not hit.
local found, err = wifi.scan()
if not found then
message = err or "scan failed"
log.error("wifi scan failed: " .. tostring(err))
show "wifi"
return
end
networks = found
log.info("wifi scan found " .. #networks .. " networks")
show "networks"
return
end
if bleScanRequested then
bleScanRequested = false
local devices, err = ble.scan(3000)
if devices then
log.info("BLE scan found " .. #devices .. " devices")
bleDevices = devices
show "ble_devices"
else
bleMessage = err
show "ble"
end
return
end
if bleConnectRequested then
bleConnectRequested = false
local ok, err = ble.connect(selectedBleDevice.address)
bleMessage = ok and "BLE connected" or err
show "ble"
return
end
if mode == "connecting" then
local status = wifi.getStatus()
if status.state == "connected" then
message = "connected: " .. status.ip
local free, _, largest = sys.getMemory()
log.info("wifi connected " .. status.ssid .. " free=" .. free .. " largest=" .. largest)
show "wifi"
elseif status.state == "failed" or status.state == "not_found" then
message = "connection " .. status.state
log.info("wifi connection " .. status.state)
show "wifi"
end
return
end
if mode ~= "calibrate" then
return
end
local rx, ry = touch.getRawPoint()
if not armed then
if not rx then
armed = true
end
return
end
if rx then
pending = { x = rx, y = ry }
elseif pending then
samples[#samples + 1] = pending
pending = nil
if #samples == 1 then
targetIndex = 2
ui.rebuild()
else
finishCalibration()
end
end
end
return M
+62
View File
@@ -0,0 +1,62 @@
-- A settings sub-screen that is one scrollable column of choices. Rotation, Theme and
-- Timezone are each this with different options and a different apply(): the current value
-- is marked, tapping one applies it and rebuilds, and the status bar's back chevron
-- returns to the Settings menu.
local ui = require "ui"
local nav = require "nav"
local M = {}
---@class Option
---@field label string
---@field value any
---@param opts { title: string, options: Option[], current: fun():any, apply: fun(value:any):boolean, string? }
---@return SlateApp
function M.app(opts)
local app = {}
local message
function app.init()
nav.setTitle(opts.title)
log.info(opts.title .. " ready")
end
function app.node()
local current = opts.current()
local list = { pad = 12, gap = 8, w = "fill" }
for _, option in ipairs(opts.options) do
-- Marker is its own fixed-width label rather than a prefix on the name, so the row
-- does not reflow when it becomes current and a test can still find it by name.
list[#list + 1] = ui.button {
row = true,
w = "fill",
gap = 8,
on_click = function()
local ok, err = opts.apply(option.value)
if ok then
message = nil
else
message = err or "save failed"
end
ui.rebuild()
end,
ui.label(option.value == current and ">" or " ", { font = screen.FONT_UI, w = 10 }),
ui.label(option.label, { font = screen.FONT_UI }),
}
end
if message then
list[#list + 1] = ui.text(message)
end
-- Scrolled against the frame, not the panel: a long list (timezones) overflows and
-- pans, a short one (rotation) just sits at the top.
local _, h = ui.frame()
return ui.box { w = "fill", h = h, scrollY = true, ui.box(list) }
end
return app
end
return M
+9 -2
View File
@@ -20,7 +20,7 @@ local M = { height = BAR_H }
local ids = {}
local title, shownBars, shownOffline
local hidden = false
local hidden, built = false, false
local function clock()
return sys.isClockSynced() and os.date "%H:%M:%S" or "--:--:--"
@@ -90,6 +90,9 @@ end
---@return NodeId
function M.node()
-- Reached only through ui.mount()'s rebuild, so a tree now exists and setFullscreen()
-- may repaint.
built = true
local theme = ui.theme
title = nav.getTitle()
local clockW = screen.getTextWidth(FONT, "00:00:00")
@@ -173,7 +176,11 @@ function M.setFullscreen(on)
return
end
hidden = on
ui.rebuild()
-- Rebuild only once a tree exists: an app init() runs before ui.mount(), and its
-- fullscreen request is picked up by that mount instead.
if built then
ui.rebuild()
end
end
return M
+7
View File
@@ -0,0 +1,7 @@
return {
rotation = 0,
timezone = "UTC",
theme = "light",
touch = { x0 = 240, y0 = 3800, x1 = 3800, y1 = 200 },
wifi = { ssid = "qemu", password = "" },
}
+4
View File
@@ -1,5 +1,6 @@
#include "lua_host.h"
#include "../net.h"
#include "../settings.h"
esp32lua::Providers LuaHost::wire() {
@@ -127,6 +128,9 @@ void LuaHost::pollTimers() {
}
void LuaHost::navigate() {
// No app is built while the radio churns the heap. An app that wants WiFi
// rejoins after it is built; leaving it drops the join again here.
net::stopWifi();
prepareForApp();
if (!runtime.applyPendingNavigation()) {
fail("app failed to start");
+1 -4
View File
@@ -86,14 +86,11 @@ void setup() {
return;
}
// Load Settings & Load Lua
// Load Settings & Initialize Network
settings.load();
net::begin();
if (!host.begin())
fallbackScreen("home failed to start");
// Start WiFi
net::startWifi();
}
void loop() {
+18 -37
View File
@@ -11,13 +11,7 @@ namespace {
bool synced = false;
bool wasConnected = false;
volatile bool wifiShutdownPending = false;
// The device cannot be running before its own firmware was compiled, so the
// build timestamp is a safe floor. Certificate validity checks fail against
// 1970, and this makes them pass before SNTP has answered. mktime reads it as
// UTC while the build machine wrote local time; a few hours of error is
// irrelevant for a lower bound.
time_t buildTime() {
static const char months[] = "JanFebMarAprMayJunJulAugSepOctNovDec";
char month[4] = {__DATE__[0], __DATE__[1], __DATE__[2], '\0'};
@@ -32,13 +26,9 @@ time_t buildTime() {
return mktime(&parts);
}
// Latched, because sntp_get_sync_status() reports COMPLETED only briefly before
// resetting to wait for the next cycle; polling it would flap.
void onTimeSync(struct timeval*) {
const bool first = !synced;
synced = true;
if (first)
wifiShutdownPending = true;
Serial.printf("[net] clock synced: %lu\n", (unsigned long)time(nullptr));
}
@@ -50,43 +40,36 @@ void net::applyTimezone() {
}
void net::begin() {
// Apply Timezone
applyTimezone();
// Floor to Build
time_t floor = buildTime();
if (time(nullptr) < floor) {
struct timeval seed = {.tv_sec = floor, .tv_usec = 0};
settimeofday(&seed, nullptr);
}
// Latch
sntp_set_time_sync_notification_cb(onTimeSync);
WiFi.persistent(false);
}
// Deferred until the Lua runtime has allocated, so the driver's first init
// faces the same heap shape as every later one. Starting it against a pristine
// boot heap wins blocks that cannot be reassembled once Lua is resident.
void net::startWifi() {
if (settings.wifiSsid.isEmpty())
return;
logHeap("pre-wifi");
WiFi.mode(WIFI_STA);
WiFi.begin(settings.wifiSsid.c_str(), settings.wifiPassword.c_str());
logHeap("wifi-start");
}
void net::loop() {
if (wifiShutdownPending) {
wifiShutdownPending = false;
esp_sntp_stop();
WiFi.mode(WIFI_OFF);
wasConnected = false;
logHeap("wifi-off");
return;
}
void net::stopWifi() {
if (WiFi.getMode() == WIFI_MODE_NULL)
return;
// Stop WiFi
esp_sntp_stop();
WiFi.mode(WIFI_OFF);
wasConnected = false;
logHeap("wifi-off");
}
void net::loop() {
if (WiFi.getMode() == WIFI_MODE_NULL)
return;
// Store Connection
bool connected = WiFi.status() == WL_CONNECTED;
if (connected == wasConnected)
return;
@@ -94,10 +77,8 @@ void net::loop() {
if (!connected)
return;
// Started per join rather than once at boot: a fresh lease may hand us a
// different NTP server, and the daemon re-syncs on its own hourly from here.
// SNMP Sync
sntp_servermode_dhcp(1);
// configTime() would overwrite TZ with UTC, so hand it the saved rule.
configTzTime(settings.timezone.c_str(), "pool.ntp.org", "time.nist.gov");
Serial.println("[net] wifi up, sntp started");
}
+11 -4
View File
@@ -1,13 +1,20 @@
#pragma once
// Background network housekeeping: joining the saved network and keeping the
// clock honest. Nothing here blocks, so the UI keeps running while WiFi and
// SNTP settle.
// Network housekeeping around an on-demand radio. An app joins through
// wifi.connect() when it needs the network; nothing here auto-joins, so the
// radio is off while a Lua state is built. Nothing blocks, so the UI keeps
// running while SNTP settles.
namespace net {
void begin();
void startWifi();
// Powers the radio down now, synchronously. The launch path calls this before
// building the next app: WiFi and lwIP churn the heap as packets arrive, and a
// Lua state built alongside that churn fragments the one large block it needs.
// Idempotent - a no-op when the radio is already off.
void stopWifi();
void loop();
// Pushes settings.timezone into libc, so os.date() in Lua reports local time.
+2 -3
View File
@@ -1,13 +1,12 @@
-- Run: lua test/ble.lua
package.path = "sdcard/.lua/lib/?.lua;test/?.lua;" .. package.path
package.path = "sdcard/.lua/lib/?.lua;lib/esp32-lua-api/lua/lib/?.lua;test/?.lua;" .. package.path
local device = require "fake_device"
device.bleDevices = { { name = "Sensor", address = "aa:bb:cc:dd:ee:ff", rssi = -42 } }
device.install()
local app = device.start "sdcard/.lua/apps/Settings/main.lua"
local app = device.start "sdcard/.lua/apps/Settings/Ble/main.lua"
device.tap "BLE"
assert(device.labelled "off")
assert(not device.find "disconnect")
+1 -1
View File
@@ -3,7 +3,7 @@
-- is the part that decides what a tap enters, and it is pure, so it is asserted here.
-- Painting, press highlighting and the 80 ms hold need the panel and the node tree, so
-- they are emulator behaviour.
package.path = "sdcard/.lua/lib/?.lua;test/?.lua;" .. package.path
package.path = "sdcard/.lua/lib/?.lua;lib/esp32-lua-api/lua/lib/?.lua;test/?.lua;" .. package.path
require("fake_device").install()
local keyboard = require "keyboard"
+2 -3
View File
@@ -6,12 +6,11 @@
--
-- Where the notice lands is layout: test/ui_layout_test.cpp asserts centring against the
-- same C++ the panel runs.
package.path = "sdcard/.lua/lib/?.lua;test/?.lua;" .. package.path
package.path = "sdcard/.lua/lib/?.lua;lib/esp32-lua-api/lua/lib/?.lua;test/?.lua;" .. package.path
local device = require("fake_device").install()
device.start "sdcard/.lua/apps/Settings/main.lua"
device.tap "WiFi"
device.start "sdcard/.lua/apps/Settings/Wifi/main.lua"
local drawnBefore = device.drawn
device.tap "scan networks"
+66 -64
View File
@@ -1,23 +1,34 @@
-- Run: lua test/settings_calibration.lua
-- Drives the settings app on a desktop Lua, through the shared fake device. Controls are
-- pressed by label rather than by coordinate: where a row lands is layout, and layout is
-- asserted in test/ui_layout_test.cpp against the C++ the panel actually runs.
package.path = "sdcard/.lua/lib/?.lua;test/?.lua;" .. package.path
-- Drives the settings apps on a desktop Lua, through the shared fake device. Settings is a
-- menu of sub-apps now: the menu launches routes, and each setting is its own app started
-- on its own. Controls are pressed by label, because where a row lands is layout and layout
-- is asserted in test/ui_layout_test.cpp against the C++ the panel runs.
package.path = "sdcard/.lua/lib/?.lua;lib/esp32-lua-api/lua/lib/?.lua;test/?.lua;" .. package.path
local device = require("fake_device").install()
local APP = "sdcard/.lua/apps/Settings/"
local SETTINGS = "sdcard/.lua/apps/Settings/main.lua"
local app = device.start(SETTINGS)
local tapRow = device.tap
-- Relaunching is how a test gets a clean screen, because that is what the firmware does:
-- a new app is a new state that builds itself from nothing.
local function restart()
app = device.start(SETTINGS)
-- The menu launches one route per card, and nothing else: tapping a card only records the
-- navigation the firmware would perform.
device.start(APP .. "main.lua")
local routes = {
Calibrate = "Settings/Calibrate",
Rotation = "Settings/Rotation",
WiFi = "Settings/Wifi",
BLE = "Settings/Ble",
Theme = "Settings/Theme",
Timezone = "Settings/Timezone",
}
for label, route in pairs(routes) do
device.started = nil
device.tap(label)
assert(device.started, label .. " launched nothing")
assert(device.started.args.app == route, label .. " launched " .. tostring(device.started.args.app))
end
-- A perfectly linear panel spanning raw 200..3800 over 320x480 must round-trip
-- to those same extremes from the two inset samples.
-- computeCalibration: a perfectly linear panel spanning raw 200..3800 over 320x480 must
-- round-trip to those extremes from the two inset samples.
local calibrate = device.start(APP .. "Calibrate/main.lua")
local inset, w, h = 30, 320, 480
local function raw(pixel, size)
return 200 + (3800 - 200) * pixel / size
@@ -25,7 +36,7 @@ end
local s1 = { x = raw(inset, w), y = raw(inset, h) }
local s2 = { x = raw(w - inset, w), y = raw(h - inset, h) }
local x0, y0, x1, y1 = app.computeCalibration(s1, s2, w, h, inset)
local x0, y0, x1, y1 = calibrate.computeCalibration(s1, s2, w, h, inset)
assert(math.abs(x0 - 200) <= 1, "x0 " .. x0)
assert(math.abs(y0 - 200) <= 1, "y0 " .. y0)
assert(math.abs(x1 - 3800) <= 1, "x1 " .. x1)
@@ -34,83 +45,74 @@ assert(math.abs(y1 - 3800) <= 1, "y1 " .. y1)
-- A flipped panel (raw decreasing with pixel) must yield a descending range.
local s3 = { x = 3800 - raw(inset, w) + 200, y = s1.y }
local s4 = { x = 3800 - raw(w - inset, w) + 200, y = s2.y }
local fx0, _, fx1 = app.computeCalibration(s3, s4, w, h, inset)
local fx0, _, fx1 = calibrate.computeCalibration(s3, s4, w, h, inset)
assert(fx0 > fx1, "flipped axis should descend")
-- Timezone cycles through the picker list.
local zones = require "timezones"
restart()
tapRow "Timezone"
assert(sys.getTimezone() == zones[2].tz, "timezone " .. sys.getTimezone())
-- Cycling rebuilds the screen, which is the only way a screen changes now, so the card
-- shows the new zone and the menu is still the same size.
local beforeCycle = tree.getCount()
assert(device.labelled(zones[2].name), "timezone card did not take its new value")
assert(tree.getCount() == beforeCycle, "the rebuilt menu grew")
tapRow "Timezone"
assert(sys.getTimezone() == zones[3].tz, "timezone " .. sys.getTimezone())
-- Rotation cycles through the four quarter turns and wraps back to 0.
restart()
for _, expected in ipairs { 90, 180, 270, 0 } do
tapRow "Rotation"
assert(screen.getRotation() == expected, "rotation " .. screen.getRotation())
end
-- Calibration collects one sample per target and saves on the second release.
restart()
tapRow "Calibrate"
app.tick() -- release after the menu tap arms sampling
-- Calibration collects one sample per target and saves on the second release. Starting the
-- app is entering calibration, so the first tick arms sampling after the launch tap lifts.
calibrate = device.start(APP .. "Calibrate/main.lua")
calibrate.tick()
device.raw = { s1.x, s1.y }
app.tick()
calibrate.tick()
device.raw = nil
app.tick()
calibrate.tick()
device.raw = { s2.x, s2.y }
app.tick()
calibrate.tick()
device.raw = nil
app.tick()
calibrate.tick()
local saved = device.calibration
assert(saved, "calibration was not saved")
assert(math.abs(saved[1] - 200) <= 1, "saved x0 " .. saved[1])
-- WiFi controls reflect connection state without exposing configured network details on the card.
-- Rotation is an option list: tapping a quarter turn applies it and marks the new current.
device.rotation = 0
local rotation = device.start(APP .. "Rotation/main.lua")
device.tap "90 degrees"
assert(screen.getRotation() == 90, "rotation " .. screen.getRotation())
rotation = device.start(APP .. "Rotation/main.lua")
device.tap "270 degrees"
assert(screen.getRotation() == 270, "rotation " .. screen.getRotation())
-- Timezone is the same list, keyed by the picker's zones.
local zones = require "timezones"
device.timezone = zones[1].tz
device.start(APP .. "Timezone/main.lua")
device.tap(zones[3].name)
assert(sys.getTimezone() == zones[3].tz, "timezone " .. sys.getTimezone())
-- WiFi controls reflect connection state without exposing configured network details.
device.status = { state = "disconnected", ssid = "", ip = "", rssi = 0 }
restart()
tapRow "WiFi"
device.start(APP .. "Wifi/main.lua")
assert(not device.find "connect" and not device.find "disconnect", "unconfigured wifi has no toggle")
device.status = { state = "disconnected", ssid = "saved", ip = "", rssi = 0 }
restart()
tapRow "WiFi"
device.start(APP .. "Wifi/main.lua")
assert(device.find "connect" and not device.find "disconnect", "configured wifi can reconnect")
tapRow "connect"
device.tap "connect"
assert(device.wifiReconnect, "wifi reconnect should use saved credentials")
device.status = { state = "connected", ssid = "saved", ip = "192.168.1.2", rssi = -40 }
restart()
tapRow "WiFi"
local wifiApp = device.start(APP .. "Wifi/main.lua")
assert(device.find "disconnect" and not device.find "connect", "connected wifi can disconnect")
tapRow "disconnect"
device.tap "disconnect"
assert(device.status.state == "disconnected" and device.status.ssid == "saved", "disconnect preserves wifi intent")
-- Open networks connect directly from scan results.
restart()
wifiApp = device.start(APP .. "Wifi/main.lua")
device.networks = { { ssid = "qemu", rssi = -25, secure = false } }
tapRow "WiFi"
tapRow "scan networks"
app.tick()
tapRow "qemu"
device.tap "scan networks"
wifiApp.tick()
device.tap "qemu"
assert(device.connected, "open network should connect without a keyboard")
assert(device.connected[1] == "qemu" and device.connected[2] == "", "open wifi connect")
-- Secure networks route through the keyboard and preserve typed punctuation.
restart()
wifiApp = device.start(APP .. "Wifi/main.lua")
device.networks = { { ssid = "secure", rssi = -40, secure = true } }
tapRow "WiFi"
tapRow "scan networks"
app.tick()
tapRow "secure"
app.tick()
device.tap "scan networks"
wifiApp.tick()
device.tap "secure"
wifiApp.tick()
-- The keyboard is one custom node with no child per key, so its keys are reached through
-- its own geometry rather than by label. getRect() answers the same box to the test and
+1 -1
View File
@@ -2,7 +2,7 @@
-- The bar is part of the tree, so a tick has to repaint the field that changed and leave
-- the rest of the screen alone. The clock is pinned unsynced so its text is constant and
-- every other field can be steered on its own.
package.path = "sdcard/.lua/lib/?.lua;test/?.lua;" .. package.path
package.path = "sdcard/.lua/lib/?.lua;lib/esp32-lua-api/lua/lib/?.lua;test/?.lua;" .. package.path
local device = require("fake_device").install()
local ui = require "ui"