diff --git a/.gitignore b/.gitignore index 8a477fb..6025af9 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.pi/skills/test-e32r40t-firmware/SKILL.md b/.pi/skills/test-e32r40t-firmware/SKILL.md index d9abb72..80ec6d7 100644 --- a/.pi/skills/test-e32r40t-firmware/SKILL.md +++ b/.pi/skills/test-e32r40t-firmware/SKILL.md @@ -130,8 +130,9 @@ esp-emu --board e32r40t boot .pio/build/esp32-32e/firmware.bin --sdcard card.img ``` Apps are `/.lua/apps//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 diff --git a/Makefile b/Makefile index bcbaf83..9f930d1 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/sdcard/.lua/apps/Settings/Ble/main.lua b/sdcard/.lua/apps/Settings/Ble/main.lua new file mode 100644 index 0000000..5d52359 --- /dev/null +++ b/sdcard/.lua/apps/Settings/Ble/main.lua @@ -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 diff --git a/sdcard/.lua/apps/Settings/Calibrate/main.lua b/sdcard/.lua/apps/Settings/Calibrate/main.lua new file mode 100644 index 0000000..37c7f01 --- /dev/null +++ b/sdcard/.lua/apps/Settings/Calibrate/main.lua @@ -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 diff --git a/sdcard/.lua/apps/Settings/Rotation/main.lua b/sdcard/.lua/apps/Settings/Rotation/main.lua new file mode 100644 index 0000000..b6ad95a --- /dev/null +++ b/sdcard/.lua/apps/Settings/Rotation/main.lua @@ -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, +} diff --git a/sdcard/.lua/apps/Settings/Theme/main.lua b/sdcard/.lua/apps/Settings/Theme/main.lua new file mode 100644 index 0000000..8c29527 --- /dev/null +++ b/sdcard/.lua/apps/Settings/Theme/main.lua @@ -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, +} diff --git a/sdcard/.lua/apps/Settings/Timezone/main.lua b/sdcard/.lua/apps/Settings/Timezone/main.lua new file mode 100644 index 0000000..12edd36 --- /dev/null +++ b/sdcard/.lua/apps/Settings/Timezone/main.lua @@ -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, +} diff --git a/sdcard/.lua/apps/Settings/Wifi/main.lua b/sdcard/.lua/apps/Settings/Wifi/main.lua new file mode 100644 index 0000000..8b455f7 --- /dev/null +++ b/sdcard/.lua/apps/Settings/Wifi/main.lua @@ -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 diff --git a/sdcard/.lua/apps/Settings/main.lua b/sdcard/.lua/apps/Settings/main.lua index a99f99e..c4a1696 100644 --- a/sdcard/.lua/apps/Settings/main.lua +++ b/sdcard/.lua/apps/Settings/main.lua @@ -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 diff --git a/sdcard/.lua/lib/optionlist.lua b/sdcard/.lua/lib/optionlist.lua new file mode 100644 index 0000000..90720f9 --- /dev/null +++ b/sdcard/.lua/lib/optionlist.lua @@ -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 diff --git a/sdcard/.lua/lib/statusbar.lua b/sdcard/.lua/lib/statusbar.lua index bda6925..d071af0 100644 --- a/sdcard/.lua/lib/statusbar.lua +++ b/sdcard/.lua/lib/statusbar.lua @@ -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 diff --git a/sdcard/settings.lua b/sdcard/settings.lua new file mode 100644 index 0000000..dd1ecf8 --- /dev/null +++ b/sdcard/settings.lua @@ -0,0 +1,7 @@ +return { + rotation = 0, + timezone = "UTC", + theme = "light", + touch = { x0 = 240, y0 = 3800, x1 = 3800, y1 = 200 }, + wifi = { ssid = "qemu", password = "" }, +} diff --git a/src/host/lua_host.cpp b/src/host/lua_host.cpp index 693d034..42fe31c 100644 --- a/src/host/lua_host.cpp +++ b/src/host/lua_host.cpp @@ -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"); diff --git a/src/main.cpp b/src/main.cpp index c064298..2923d9e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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() { diff --git a/src/net.cpp b/src/net.cpp index 943c98c..caed011 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -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"); } diff --git a/src/net.h b/src/net.h index 85a9e78..7c092a8 100644 --- a/src/net.h +++ b/src/net.h @@ -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. diff --git a/test/ble.lua b/test/ble.lua index 30f6d4e..688d8a5 100644 --- a/test/ble.lua +++ b/test/ble.lua @@ -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") diff --git a/test/keyboard.lua b/test/keyboard.lua index e739a0e..6979a20 100644 --- a/test/keyboard.lua +++ b/test/keyboard.lua @@ -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" diff --git a/test/settings_busy.lua b/test/settings_busy.lua index 01357c3..4c93020 100644 --- a/test/settings_busy.lua +++ b/test/settings_busy.lua @@ -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" diff --git a/test/settings_calibration.lua b/test/settings_calibration.lua index 50af3e1..3dfdf98 100644 --- a/test/settings_calibration.lua +++ b/test/settings_calibration.lua @@ -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 diff --git a/test/statusbar_dirty.lua b/test/statusbar_dirty.lua index fb8ecf7..304dd24 100644 --- a/test/statusbar_dirty.lua +++ b/test/statusbar_dirty.lua @@ -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"