97abf037b8
Follows the submodule: on_touch_down and friends become onTouchDown, the last snake_case left in the surface now that handlers are table fields rather than globals. main.lua declares SlateApp, this card's contract with its apps, and every app composes what it fills -- PaintApp is SlateApp plus TouchHandlers, Home is SlateApp alone -- which is also the only static record of the features an app needs.
555 lines
16 KiB
Lua
555 lines
16 KiB
Lua
local ui = require "ui"
|
|
local statusbar = require "statusbar"
|
|
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
|
|
if zone.tz == current then
|
|
return zone.name
|
|
end
|
|
end
|
|
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 spec = {
|
|
w = side,
|
|
h = side,
|
|
gap = 6,
|
|
justify = "center",
|
|
align = "center",
|
|
on_click = on_click,
|
|
ui.label(title, { font = screen.FONT_UI, fit = side - 12 }),
|
|
}
|
|
if value then
|
|
spec[#spec + 1] = ui.label(value, { font = screen.FONT_SMALL, fit = side - 12 })
|
|
end
|
|
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)
|
|
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),
|
|
}
|
|
|
|
-- 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
|
|
local row = { row = true, gap = MENU_GAP, w = gridW }
|
|
for column = index, math.min(index + cols - 1, #cards) do
|
|
row[#row + 1] = cards[column]
|
|
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,
|
|
},
|
|
}
|
|
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
|