apps: refactor settings
This commit is contained in:
+3
-1
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
return {
|
||||
rotation = 0,
|
||||
timezone = "UTC",
|
||||
theme = "light",
|
||||
touch = { x0 = 240, y0 = 3800, x1 = 3800, y1 = 200 },
|
||||
wifi = { ssid = "qemu", password = "" },
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user