14 Commits

Author SHA1 Message Date
evan 61c2f9a6fb refactor(ui): adopt tree-owned scrolling and the ui.* module namespace
Scroll and optionlist drop their own drag/flick controllers now that
ui.lua owns them; main forwards deltaMs to ui.draw. keyboard, optionlist
and statusbar move under ui.* -- keyboard into the shared submodule,
optionlist/statusbar to sdcard/.lua/lib/ui. Bumps esp32-lua-api.
2026-08-06 17:08:07 -04:00
evan 78f2286b73 apps: refactor settings 2026-08-06 15:28:44 -04:00
evan e92a8a4cc9 feat: scrolling 2026-08-06 11:22:54 -04:00
evan 0c0365fe45 docs: drop the crosspoint-reader parity comparison
It compared against gui.*, sys.delay, on_tick and a required init(), none
of which exist on either firmware now. The comparison was a stand-in for a
shared API; lib/esp32-lua-api is that API, so agreement is enforced by the
build rather than recorded in prose.
2026-08-05 17:11:13 -04:00
evan b8572fb422 docs: bring the README back in line with the shared runtime
The module table still described gui/input/settings, the tree still named
src/lua and a stubs generator, and the test list named two files that do
not exist. Parity with crosspoint-reader is now a submodule rather than a
comparison document, so the intro says where the platform actually lives.
2026-08-05 17:10:04 -04:00
evan 1adf121dc6 feat(nav)!: own routing in Lua now that the runtime only carries arguments
sys.startApp(path, args) tears the runtime down and starts over from a
file, and keeps nothing else: no history, no title, no app identity. All
of that can ride in the arguments, so it does, and sdcard/.lua/lib/nav.lua
is the single writer of that table.

nav owns the back stack, the bar's title and /.lua/data/<AppId>, which
means the launcher, what "back" reaches and how deep a route nests are
editable on the card rather than in a reflash. Apps call nav.launch,
nav.replace and nav.back; sys.startApp has one caller.

Boot passes no arguments at all, which is how main.lua tells a cold start
from a navigation and opens its own launcher, replacing the home field the
runtime used to read. Arguments cross as JSON, so history is a list of
tables rather than a packed string, and an app passing something JSON
cannot carry sees the error at its own nav call.
2026-08-05 17:04:49 -04:00
evan 575f31e7ee docs(skill): correct emulator sync points and tap targets
Log level prints after the tag, the app's own log precedes the host's running line, wait-frame/sleep are unavailable, and the card grid is two columns.
2026-08-05 11:00:00 -04:00
evan 97abf037b8 refactor(lua)!: camelCase handlers, and type the app tables
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.
2026-08-05 10:42:06 -04:00
evan 7bc9afae33 refactor(lua)!: migrate to the per-feature namespaces
Follows lib/esp32-lua-api: gui -> screen, node -> tree, settings and input
split into screen/sys/touch/buttons. Settings is one provider lighter, with
timezone on Sys and rotation and theme on Gui, which now applies and persists a
rotation in one call. The calibration screen stashes the rotation it borrows
rather than relying on a transient setter.
2026-08-05 10:26:43 -04:00
evan 823a664af7 fix(settings): report a failed scan instead of indexing nil
wifi.scan() returns nil and a reason when the radio cannot allocate, which
took the app down with it.
2026-08-04 21:30:25 -04:00
evan 1aa3a59ca9 feat: give /.lua/main.lua the whole screen and the app contract
The firmware knew where apps, data and modules lived, called four globals,
and painted a status bar into a strip it clipped every app out of. None of
that was its business, and the viewport made the bar something an app could
neither compose with nor replace.

It now loads one file. main.lua mounts the route inside its own node tree,
so the bar is a sibling of the app rather than chrome painted over it: one
layout, one hit test, no inset arithmetic and no invalidation flags on the
C++ side. Apps and main.lua are tables -- they share a lua_State, so globals
would collide -- and a screen changes by rebuilding from node().
2026-08-04 21:15:21 -04:00
evan a60d054359 feat(power): dim the backlight after ten idle seconds 2026-08-04 20:44:13 -04:00
evan 0a620713df fix(net): start SNTP with the saved timezone
configTime() sets TZ to UTC, which undid the saved rule on every join.
2026-08-04 20:44:13 -04:00
evan b3b5a9b2a3 feat(settings): persist the theme through the settings binding
Keeps one writer for saved state: ui.setTheme() writes through
settings.setTheme() and then reloads the palette C cannot see.
2026-08-04 20:44:13 -04:00
43 changed files with 1912 additions and 1789 deletions
+3 -1
View File
@@ -3,7 +3,9 @@
/compile_commands.json
_scratch/
# Shared Lua modules are copied in from lib/esp32-lua-api by `make sdcard`.
# Shared modules ship as stripped bytecode inside the firmware. A copy here would
# shadow it with source, which costs ~16KB of heap per live Lua state; keep them
# ignored so a debugging copy never gets committed.
sdcard/.lua/lib/ui.lua
sdcard/.lua/lib/hints.lua
/.lua/
+30 -21
View File
@@ -26,17 +26,20 @@ The app image is `.pio/build/esp32-32e/firmware.bin`; `bootloader.bin` and `part
## Scripted Test
Use an executable `.e32r40t` script. Assert on the app path the firmware logs rather than assuming which row a tap hits:
Use an executable `.e32r40t` script. Every app is loaded through `/.lua/main.lua`, so that is
the only path the firmware ever logs -- `wait-log "running Scroll"` matches nothing and burns
the whole timeout. Wait for a log the app itself writes in `init()`:
```text
#!/usr/bin/env -S esp-emu --board e32r40t
boot .pio/build/esp32-32e/firmware.bin --sdcard sdcard/ --fresh-sd
wait-log "launcher ready" --timeout 120
wait-log "\[lua\] info: home ready" --timeout 180
wait-idle 3
tap 60 55
wait-log "running Hello"
wait-log "\[lua\] .* started"
tap 85 127
wait-log "\[lua\] info: hello ready"
wait-idle 3
capture _scratch/emulator-app.png
```
@@ -50,27 +53,28 @@ Blank lines and `#` comments are allowed. Scripts also run explicitly or through
```sh
esp-emu --board e32r40t --state /tmp/my-test run flow.e32r40t
printf '%s\n' 'boot .pio/build/esp32-32e/firmware.bin' 'wait-log "launcher ready"' | esp-emu --board e32r40t run
printf '%s\n' 'boot .pio/build/esp32-32e/firmware.bin' 'wait-log "info: home ready"' | esp-emu --board e32r40t run
```
## Synchronization
Lua logs carry a level in the prefix (`[lua:info] home ready`), so match `\[lua:info\]` and
not `\[lua\]`.
`LogProvider` prints the level after the tag -- `[lua] info: home ready` -- so match
`\[lua\] info:` and never `\[lua:info\]`, which matches nothing and burns the whole timeout.
Firmware sync points, in order: `running <path>` (printed by the host for every app,
including the launcher itself), `[lua:info] home ready` (menu
drawn, touch accepted), then whatever the app logs through `log.info(...)`. Exiting an
app relaunches the launcher, so both lines repeat.
Firmware sync points, in order: an app's own `log.info(...)`, then `running <path>` printed
by the host for every app including the launcher. The app line comes **first**, because
`init()` runs inside `startApp()` and the host logs the route after it returns. Wait for
`[lua] info: home ready` and then `running Home`. Exiting an app relaunches the launcher, so
both lines repeat.
A scripted tap must land after the app is idle: `setup()` can block for seconds on a
full-screen clear under TCG, and a 250 ms press that lands during it is missed
entirely. Wait for the app's own ready log, then `sleep` a few seconds.
entirely. Wait for the app's own ready log, then `wait-idle 3`.
- Use `wait-log REGEX` as the default synchronization and assertion mechanism.
- Assert on the `running ...` line rather than on tap coordinates: menu rows follow SD directory order, which the image build decides.
- Use `wait-frame [COUNT]` only when a panel redraw is itself the behavior under test.
- Use `wait-idle SECONDS` only when silence is the actual readiness signal.
- Use `wait-idle SECONDS` between a ready log and the first tap, and between a tap and a capture.
- `wait-frame` is an e-ink command and hangs on this board; `sleep` is not a command at all. A script using either stops silently.
- Capture and inspect the final screen after semantic log assertions.
## Touch
@@ -78,12 +82,16 @@ entirely. Wait for the app's own ready log, then `sleep` a few seconds.
`tap` always takes physical panel pixels (320x480 portrait), because the glass never rotates.
Screens are laid out by `/.lua/lib/ui.lua`, so read tap targets off a `capture` rather than
computing them. The status bar takes the top 44 rows; card grids use three portrait columns
and four landscape columns. With the current apps, portrait Home card centres are near
`(109,102)` and `(210,102)`, while Settings' first row is near x=59/160/261 at y=102.
computing them. The status bar takes the top 44 rows and card grids are two columns wide in
portrait. With the current apps, portrait Home card centres are near `(85,127)` / `(235,127)`
and `(85,275)` / `(235,275)`, and Settings' rows sit at x=85/235 with y=115/240/375.
Taps that land within the first few seconds after a ready log are missed, so `wait-idle 3`
before the first one.
A grid reflows when the frame does, so the rotated launcher is not the portrait one turned
sideways -- its cards move and change count per row. After any rotation, capture and read the
new targets instead of converting the old ones.
Use `touch-hold` plus `capture` plus `touch-release` to photograph a pressed button:
`on_press` fires on release, and the pressed style is only visible mid-gesture.
@@ -122,12 +130,13 @@ 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
Pure Lua helpers run far faster on the host than in the emulator. Stub the `gui`, `input`, `sys` and `log` tables and load the app directly, as `test/settings_calibration.lua` does:
Pure Lua helpers run far faster on the host than in the emulator. Stub the `screen`, `tree`, `touch`, `sys` and `log` tables and load the app directly, as `test/settings_calibration.lua` does -- or use `test/fake_device.lua`, which is the single definition of that binding surface:
```sh
nix run nixpkgs#lua -- test/settings_calibration.lua
+154 -47
View File
@@ -2,8 +2,8 @@
ESP32 firmware (C++/Arduino) hosting Lua apps off an SD card. The Lua platform -- bindings,
the widget tree, app loading and navigation -- is shared with crosspoint-reader and lives in
`lib/esp32-lua-api`. This repository owns the panel, touch, network, persistence and chrome;
everything user-visible is Lua under `sdcard/.lua/`.
`lib/esp32-lua-api`. This repository owns the panel, touch, network and persistence; everything
user-visible, chrome included, is Lua under `sdcard/.lua/`, entered through `main.lua`.
## Build and Test
@@ -20,23 +20,24 @@ not here; `lua/api/**` there is generated from the C++ that registers it, so run
## Binding Conventions
Accessors are `getName` / `setName` / `isName`. Bare names are actions (`gui.fillRect`,
`wifi.scan`) or pure conversions (`gui.color`, `http.urlencode`). A missing getter is fine;
Accessors are `getName` / `setName` / `isName`. Bare names are actions (`screen.fillRect`,
`wifi.scan`) or pure conversions (`screen.color`, `http.urlencode`). A missing getter is fine;
an accessor without a prefix is not.
Namespaces have one job each: `gui` device primitives and the live frame, `node` the widget
tree, `ui` widgets and palette, `settings` persisted preferences, `sys` process and runtime,
plus `wifi`/`http`/`fs`/`input`/`log`. `ble` uses NimBLE-Arduino because Bluedroid cannot
**Every namespace belongs to exactly one feature, or to core.** `screen` panel primitives and
its saved rotation and theme, `tree` the widget tree, `touch` the panel's input and its
calibration, `buttons` the button roles — all four are the `screen` and `touch`/`buttons`
features. Core is `sys` (process, runtime, timezone), `ui` widgets and palette, plus
`wifi`/`http`/`fs`/`log`/`timer`. `ble` uses NimBLE-Arduino because Bluedroid cannot
initialize beside the Lua runtime; reserve `bt` and its full-ESP32 backend for future Classic Bluetooth.
**A setter that requires a follow-up call is a bug in the setter.** `settings.setTimezone()`
applies the TZ itself; `settings.setRotation()` applies the frame and re-clips. The one
unavoidable exception is the theme, because C cannot reload the Lua palette — `ui.setTheme()`
is the seam that pairs them, and apps call that, never `settings.setTheme()`.
**Persisted intent is not live state.** `settings.getRotation()` is what the user saved;
`gui.getRotation()` is the frame being drawn. They diverge on purpose while an app rotates
the panel transiently (touch calibration does). Never resolve one from the other.
**A setter that requires a follow-up call is a bug in the setter.** `sys.setTimezone()`
applies the TZ itself; `screen.setRotation()` rotates the panel, persists the choice and
re-clips, so there is one rotation rather than a live one and a saved one to reconcile. An app
that rotates the panel for its own purposes puts the old value back (touch calibration does).
The one unavoidable exception is the theme, because C cannot reload the Lua palette —
`ui.setTheme()` is the seam that pairs them: it writes through `screen.setTheme()`, then
rebuilds the palette and repaints. Apps call `ui.setTheme()`, never `screen.setTheme()`.
Settings live in C++ (`src/settings.h`) because the firmware reads rotation and calibration
before any `lua_State` exists, and calibration again on every touch. Lua reaches them through
@@ -53,14 +54,14 @@ a node goes in a Lua table keyed by id, which is what `on_press` itself does.
The split is by lifetime, and it is the whole design. `Node` holds what hit testing and
repainting need forever. `Spec` holds what only `measure`/`place` read -- requested size,
pad, gap, alignment -- and is dropped by `node.dropScratch()` when layout ends. **Re-layout
pad, gap, alignment -- and is dropped by `tree.dropScratch()` when layout ends. **Re-layout
rebuilds from Lua** (~8 ms, which nobody notices on a rotate) rather than retaining ~20 bytes
a node against it. `measure` writes the measured size into `w`/`h` and `place` overwrites the
same slots, because the two are never needed at once.
Style is sparse and inherited: a role unset on a node is answered by the nearest ancestor
that sets it, so a node naming no colours costs zero bytes. That is what keeps `Node` at 16.
Applying a palette is one `node.setStyle()` on a subtree root, which is how a dimmed region
Applying a palette is one `tree.setStyle()` on a subtree root, which is how a dimmed region
and the lit dialog above it are one call each.
**What is behind a node is derived, never set.** `bg` is the background a node offers its
@@ -69,27 +70,83 @@ nearest ancestor that actually paints one, or the panel. A dialog layer paints n
its card blends into the dimmed content two levels up rather than into the lit palette the
layer hands down — make it a style role and the scrim stops at the rounded corners.
**A screen is built from scratch.** The first node created after a layout resets the tree,
automatically -- an explicit reset per build entry point is a chance to forget one and grow
the arena a screen at a time. Handles from the previous screen are dead; update a live one
with `ui.setText(id, text)`.
**A screen is built from scratch, by `ui.rebuild()` and nothing else.** It resets the arena,
runs the builder `ui.mount()` was given, lays out and repaints. Building a node outside that
is an error rather than a silent reset: chrome and the app share one tree now, so a stray
`ui.box{}` would drop the bar along with the screen underneath it. Handles from the previous
build are dead; update a live one with `ui.setText(id, text)`, which is also the difference
between a clock tick and a rebuild.
Layout is `-Wall -Wextra` C++ free of Arduino headers, so `test/ui_layout_test.cpp` runs it
on the host through `make test-cpp`. Adding a **primitive** (a paint routine, a layout mode)
Layout has not run while a builder is running, so an app sizing itself to the frame reads
`ui.frame()`, not `screen.getHeight()` -- the panel is not the box the app was given. Whatever
mounts the tree sets the difference with `ui.setInset()`.
**Scrolling is the tree's, not an app's.** `ui.box{scrollX=true, scrollY=true}` measures its
children unbounded on the scrolled axes, so the content overflows the box; `ui.setScroll()`
pans it, clamped to `ui.getScrollRange()`. A scrolled axis needs a size of its own -- one
derived from the content would grow to fit it and never scroll -- and `"fill"` is the wrong
size, because it resolves against the panel rather than the pane the app was given: that is
what `ui.frame()` is for. Panning applies a **delta to the stored coordinates** rather than
placing again, because `place()` reads the `Spec` that `dropScratch()` already dropped; x/y
stay in screen space, so hit testing, `getRect` and every paint routine are unchanged. Rows
are ordinary nodes, so the painter culls them to the band and no Lua runs while painting --
which is the point. A container does not virtualize, so ~120 nodes is comfortable and a
thousand-row list still wants one purpose-built painter.
**Anything drawing past its own box is scissored to it.** A band is usually taller than the
node it covers, so a scroll container's overflowing children, and a `custom` painter that
draws where it likes, would otherwise land on whatever else the band holds -- a list row
half scrolled off the top painting over the status bar. `gui.setClip`/`clearClip` is that
scissor, nested so a custom node inside a scroll container narrows the container's box
instead of replacing it. On this panel it is `TFT_eSPI`'s viewport; restore it explicitly,
because `resetViewport()` reads a non-virtual `width()` and on a sprite answers the panel's.
Layout is `-Wall -Wextra` C++ free of Arduino headers, so `native/test/runtime_test.cpp` in
the submodule runs it on the host. Adding a **primitive** (a paint routine, a layout mode)
means C++ and a reflash; adding **composition** (`ui.confirm`, a new card) is still Lua on
the SD card. Custom painting is the seam between them: a `custom` node paints itself through
the `gui` bindings.
the `screen` bindings.
## Apps
One `lua_State` per app, closed on exit, which is why the heap returns to the same shape
after every launch instead of fragmenting. `sys.launch(path, arg)` pushes the current
route, `sys.replace(path, arg)` does not, and `sys.back()` pops it; history stores paths and
arguments, never Lua states. Apps receive the string as `init(arg)` -- states share no
memory, so a string is the whole handoff. `sys.setAppName()` retitles the bar for a screen
within an app.
**The firmware boots one path: `/.lua/main.lua`.** It loads that file into every fresh state
and calls the table it returns -- `start(args)`, `draw`, the `onTouch*` handlers. Where apps
live, what surrounds them and which callbacks an app itself sees are all decided there, in
Lua. Adding a path to C++ is the wrong fix for anything.
`sdcard/lib/keyboard.lua` paints all keys in one `ui.custom` node; a node per key would add
**`sys.startApp(path, args)` is the only navigation there is**, and `sdcard/.lua/lib/nav.lua`
is its one caller. C++ tears the state down, loads the path and hands the next state `args`
as JSON; it keeps no history, no title and no app identity, because those can ride in the
arguments and a value that cannot outlive the VM is the only thing that has to be C++'s.
So `nav` owns the back stack, the bar's title and `/.lua/data/<AppId>` -- `nav.launch`,
`nav.replace`, `nav.back`, `nav.setTitle`, `nav.getDataPath`. An app calls `nav`, never
`sys.startApp`. Boot passes no arguments at all, which is how `main.lua` knows to open its
own launcher rather than reading a `home` field the runtime no longer has.
The arguments are encoded while the sending state still lives, so passing a function or a
cycle raises at the `nav` call and leaves the app running. Anything JSON carries survives,
which is why history is a list of tables rather than a packed string.
An app is a table too: `init(arg)` for state, `node()` returning its subtree, and the raw
touch handlers only if it paints its own surface. Handlers are fields, not globals, because
`main.lua` and the app share one `lua_State` and globals would collide.
**A table is a type, so an app declares the one it fills.** `SlateApp` in `main.lua` is this
card's contract with its apps; `App` and `TouchHandlers` come from the submodule. An app
composes what it handles -- `---@class PaintApp : SlateApp, TouchHandlers` -- which is also
the only static record of which features it needs.
One `lua_State` per app, closed on exit, which is why the heap returns to the same shape
after every launch instead of fragmenting -- Lua's collector never moves an object, so a
long-lived VM beside WiFi's buffers fragments until an allocation fails hours in. Apps
receive `init(arg)`; states share no memory, so what `nav` put in the arguments is the whole
handoff. `nav.setTitle()` retitles the bar for a screen within an app, which the bar answers
with a rebuild, because a longer title needs a wider box.
JSON is `require "cjson"`, provided by the library rather than by this firmware. A global is
a contract the firmware implements; a module is one the library already did.
`ui.keyboard` (`lib/esp32-lua-api/lua/lib/ui/keyboard.lua`, embedded) paints all keys in one `ui.custom` node; a node per key would add
dozens of nodes and their styles while WiFi already holds buffers. Its geometry (`keyAt`,
`eachKey`) takes an explicit rect and no node, which is why `test/keyboard.lua` can assert
what a tap enters on the host.
@@ -99,33 +156,78 @@ number page is narrower than the letter page, and the letter page's outer keys s
otherwise. Press feedback that must not repaint the whole node is `on_down` drawing one
region and `on_unpress` restoring it after the 80 ms hold.
## Banded Repaint
**The painter composites, apps do not.** `tree.draw()` unions the dirty nodes into one
rectangle and paints it a band at a time: `gui.beginBuffer(x, y, w, h)` opens an offscreen
16bpp sprite, the tree is re-walked painting whatever overlaps that band, and `present()`
blits it. The tree *is* the display list, so a band re-walks it rather than replaying a
recorded command stream, and one push replaces a bus transaction per primitive -- which is
what lets a whole pane move smoothly. Apps draw in screen coordinates and call
`ui.invalidate(id)`; `apps/Scroll` scrolls with no band code of its own.
A band that overlaps a node paints the **whole** node clipped to the band, so a node taller
than a band is painted once per band it spans -- including a `custom` painter, whose Lua
callback is re-invoked per band. Cull inside a long custom painter; a list long enough to
matter wants virtualized nodes (build only the visible rows) rather than one tall painter.
**Band height adapts and must.** How much contiguous heap exists depends on the app, the
orientation and the fragmentation already there -- an app launch leaves ~48KB, but ~28KB
once its Lua state is live, and a 320x48 band is 30KB. `drawBanded` halves the band on a
failed allocation down to `MIN_BAND` before giving up, and a repaint that can allocate
nothing falls back to painting dirty nodes straight to the panel (which is what an e-ink
provider wants anyway -- the default `beginBuffer` refuses).
**A primitive drawing into a band must clip every write itself.** `TFT_eSprite` does not
clip `pushImage`, and a node straddling a band edge will otherwise write past the sprite and
corrupt the heap -- the failure looks like a hang in a later `present()`, not a fault where
the write happened. `paintRoundRect` therefore clamps its fills (`fillClip`) and draws
corner spans per pixel instead of pushing them. Adding a primitive means doing the same.
## Drawing Pitfalls
`screen.*` coordinate args go through `checkInt`, which rejects a non-integral float
(`108.5`) with "number has no integer representation" -- and an error raised inside a paint
or timer callback aborts silently, leaving a blank pane with no log. Floor computed
coordinates with `//` (`(w - tw) // 2`), never `/`.
A continuously repainting pane cannot be photographed: the emulator's capture returns the
last settled frame, so a running animation reads as the *previous* screen. Assert motion
from a log (`scrollY=`), and capture with the animation stopped to check the rendering.
## Touch and Drag
The firmware fires `on_touch_down` / `on_touch_move` / `on_touch_up` plus the `on_touch` tap
alias. `on_touch_move` filters 2px of XPT2046 jitter and nothing else; apps such as Paint
consume it directly so a stroke starts at the first real pixel. Keep ordinary toolkit UIs
frame-sized. If a real long-list use case appears, prefer one purpose-built painter over a
component per row.
The firmware fires `onTouchDown` / `onTouchMove` / `onTouchUp` plus the `onTouch` tap
alias. `onTouchMove` filters 2px of XPT2046 jitter and nothing else; apps such as Paint
consume it directly so a stroke starts at the first real pixel. Dragging a scroll container
is an app's job for now: C++ owns the offset and its clamping, nothing else.
**Animation belongs in `draw(deltaMs)`, timers in discrete events.** `Timer::collectDue`
reschedules from the moment it fired, so a repeating timer silently drops every tick it
misses -- `timer.every(16, ...)` advancing a fixed step per tick gives a speed that
collapses with frame time and surges when it recovers, which reads as rubbery motion rather
than as slowness. Scale by the elapsed milliseconds `draw` is handed instead, and floor once
at the point of use, since `screen.*` rejects a non-integral float.
## Status Bar
`sdcard/lib/statusbar.lua` paints the top strip; the firmware only clips apps out of it and
calls `draw(hasBack)` on a timer. **Invalidation is entirely Lua's**: `draw()` compares each
field against what it last painted and keys the whole cache on `gui.getRotation()`,
`ui.themeName` and `sys.getAppName()`. Adding a field that can change means adding a term
to that key, never a flag on the C++ side. A new app gets a fresh `lua_State`, so an empty cache already means "repaint
everything".
`ui.statusbar` (`sdcard/.lua/lib/ui/statusbar.lua`) is a subtree `main.lua` puts above the app, not something
the firmware paints. **The tree does the invalidation**: each field is its own node, so
`statusbar.tick()` on a one second timer calls `ui.setText()` and only a clock that actually
ticked repaints. There is no cache to key and nothing to drop.
The one change Lua cannot observe is an app that painted over the bar while fullscreen —
apps toggle through `statusbar.setFullscreen()`, which drops the cache. Do not add
invalidation flags on the C++ side; the facts that drive the bar are only knowable in Lua.
Fullscreen is chrome choosing not to build itself: `statusbar.setFullscreen()` flips a flag
and rebuilds, so the app's node becomes the whole tree. The firmware has no viewport, no
inset and no idea a bar exists -- it never did know when one changed, which is why that
knowledge is all on this side.
## Emulator
Full instructions in `.pi/skills/test-e32r40t-firmware/SKILL.md`. Two things that cost time:
- The host logs `[lua] running <path>` for every app; the launcher then logs `[lua] info: home
ready`. It is `/.lua/apps/Home`, an app like any other.
- An app's own `log.info` lands **before** the host's `[lua] running <path>`, because `init()`
runs inside `startApp()` and the host logs the route after it returns. Wait for
`[lua] info: home ready` first, then `running Home`, or a script waits out its timeout.
- `wait-frame` is an e-ink command and hangs on this board, and `sleep` is not a command at
all -- a script using either stops silently. Use `wait-idle SECONDS` between captures and
`wait-log` for everything semantic.
@@ -141,3 +243,8 @@ asserted in `test/ui_layout_test.cpp` against the same C++ the panel runs. So a
a control by what it says -- `device.tap("Rotation")`, `device.labelled(prefix)` -- never by
a coordinate. `device.press(id, x, y)` is for widgets with no label to aim at, like the
keyboard, whose own painter decides what a point hit.
`device.start(path)` mounts an app the way `main.lua` does, minus the bar, and returns its
table; relaunching is how a test gets a clean screen, because that is what the firmware
does. `device.invalidated` is what a tick repainted, which is how a per-field update is told
apart from a rebuild.
+1 -9
View File
@@ -5,10 +5,9 @@ LUA ?= lua
CXX ?= c++
CXXFLAGS ?= -std=c++11 -Wall -Wextra
BUILD_DIR := .pio/build/esp32-32e
SHARED_LUA := lib/esp32-lua-api/lua/lib
LUA_TESTS := test/keyboard.lua test/settings_calibration.lua test/statusbar_dirty.lua test/settings_busy.lua test/ble.lua
.PHONY: test test-lua test-cpp test-shared sdcard compiledb build upload monitor clean format
.PHONY: test test-lua test-cpp test-shared compiledb build upload monitor clean format
# C++ only: Lua is formatted on save by lua-language-server, which reads the
# same .editorconfig, so a second Lua formatter here would only fight it.
@@ -16,13 +15,6 @@ format:
@clang-format -i $(shell find src test -name '*.cpp' -o -name '*.h')
@$(MAKE) -C lib/esp32-lua-api format
# The shared modules ship from the submodule rather than a copy in this repo, so an app
# on the card and an app on the host resolve the same ui.lua. Run before flashing or
# booting the emulator, both of which read sdcard/ directly.
sdcard:
@cp $(SHARED_LUA)/*.lua sdcard/.lua/lib/
@echo "sdcard/.lua/lib <- $(SHARED_LUA)"
# The layout engine, the shared modules and the API declarations are tested in
# lib/esp32-lua-api; what is left here is this firmware's own Lua and gfx.
test: test-cpp test-lua test-shared
+132 -89
View File
@@ -5,9 +5,10 @@ card, launched from an on-screen menu, drawn with a component toolkit and themed
the card. The firmware is the runtime; everything a user sees ships as Lua.
Built for the 4.0" ESP32-32E display (lcdwiki E32R40T): ST7796S 320x480 SPI panel +
XPT2046 resistive touch + microSD. The Lua API is kept deliberately in step with
crosspoint-reader's, which runs the same kind of apps on e-ink — see
[docs/lua-api-parity.md](docs/lua-api-parity.md).
XPT2046 resistive touch + microSD. The Lua platform itself — bindings, the widget tree,
app loading — is the `lib/esp32-lua-api` submodule, shared with crosspoint-reader, which
runs the same apps on e-ink. This repository owns the panel, touch, network and
persistence; a binding changes there, not here.
## Build & flash
@@ -21,56 +22,70 @@ make monitor # serial logs
```
src/
main.cpp runtime loop and the fallback screen
settings.{h,cpp} /settings.lua persistence
main.cpp boot, the runtime loop and the fallback screen
settings.{h,cpp} /settings.lua persistence, read before any lua_State exists
net.{h,cpp} Wi-Fi bring-up and the clock sync that follows it
gfx/ drawing maths, free of Arduino headers so it is testable
lua/
lua_app.{h,cpp} app lifecycle: the lua_State, callbacks, teardown
bindings.h shared internals for the binding files
bindings/ one file per Lua table: gui, sys, input, fs, wifi
module_loader SD-backed require, loadfile and dofile
sdcard/ copied to the card: apps/ and lib/
host/
lua_host.{h,cpp} owns the Runtime: boot, navigation, touch and the frame loop
providers_*.cpp this board's half of lua/providers.h -- gui, fs, sys, net, ble
lib/esp32-lua-api/ submodule: the Lua runtime, bindings and shared modules
sdcard/ copied to the card: .lua/apps and .lua/lib
test/ host tests, run by `make test`
scripts/ gen_lua_stubs.py, which writes stubs/slate32.lua
stubs/ generated LuaLS definitions; point your editor here
docs/ lua-api-parity.md, the crosspoint-reader comparison
```
Copy `sdcard/` to the SD card root: apps live in `/apps/<name>/main.lua` and shared
Lua modules in `/lib`. Home is itself an app (`/apps/Home/main.lua`); the firmware only
draws a fallback screen if it cannot start.
Copy `sdcard/` to the SD card root. The firmware knows one path, `/.lua/main.lua`, and
that file decides the rest: apps live in `/.lua/apps/<name>/main.lua`, shared modules in
`/.lua/lib`, and the status bar is a node it puts above whichever app is mounted. Home is
itself an app; the firmware only draws a fallback screen if it cannot start.
## Lua API
Apps define `init(arg)`, which is required, plus optional `draw()` (~30fps cap), `on_touch_down(x, y)`,
`on_touch_up(x, y)`, `on_touch(x, y)` (tap alias, fired on release), and `on_tick()`
(enabled by `sys.setTickInterval(ms)`). Call `sys.back()` to return to the previous route,
which the back button in the status bar does too; an empty history returns to Home.
`/.lua/main.lua` returns the table the firmware calls: `start(args)` mounts an app, and
`draw(deltaMs)` plus the `on_touch*` handlers forward events. `args` is whatever the
previous state passed, or `nil` at boot, which is how `main.lua` knows to open the launcher.
`init` receives whatever string `sys.launch(path, arg)` passed, or `nil` from the launcher.
`launch` adds the current route to history; `sys.replace(path, arg)` does not. States share
no memory, so one string is the whole handoff; anything structured travels as a Lua literal
the receiver runs through `load()`. An app is named after its directory until
it calls `sys.setAppName("settings - wifi")`, which the status bar picks up on its next tick.
An app is a table too, returned from its `main.lua`: optional `init(arg)`, `node()`
returning the subtree for the current screen, `draw(deltaMs)` (~30fps cap), and
`on_touch_down/move/up` plus `on_touch` (tap alias, fired on release) for an app that
paints its own surface. Widgets are dispatched by the tree, so an app built from them
defines none of these. Call `nav.back()` to return to the previous route, which the back
control in the status bar does too; an empty history returns to Home.
`require` reads from the SD card: `/apps/<name>/?.lua` first, then `/lib/?.lua`.
Navigation is `require "nav"`, not a firmware call. `nav.launch(route, arg)` remembers the
current route, `nav.replace(route, arg)` does not, and `nav.back()` pops. All three go
through `sys.startApp(path, args)`, which tears the runtime down and starts over: the
firmware keeps no history and no app identity, so `nav` puts them in the arguments, which
cross as JSON. `init` receives whatever `arg` the launching route passed, or `nil` from the
launcher. An app is named after its directory until it calls `nav.setTitle("settings -
wifi")`, which the status bar picks up on its next tick.
`require` reads from the SD card: the running app's directory first, then
`/.lua/lib/?.lua`, both set by `main.lua`.
Globals are the firmware's contract, declared in `lib/esp32-lua-api/lua/api`. Modules are
`require`d: `cjson` from the library, everything else from the card.
| Module | Functions |
|---|---|
| `gui` | `getWidth()`, `getHeight()`, `clear(color)`, `fillRect(x,y,w,h,c)`, `drawRect(x,y,w,h,c)`, `fillCircle(x,y,r,c,bg)`, `drawLine(x1,y1,x2,y2,c)`, `drawText(text,x,y,fg,bg)`, `roundRect(x,y,w,h,radius,bg,top,bottom,border)`, `getFontHeight()`, `getTextWidth(text)`, `getRotation()`, `setRotation(deg)`, `setFullscreen(on)`, `color(r,g,b)` |
| `input` | `getTouch()` -> `x,y` or nil, `getRawTouch()` -> raw ADC `x,y` or nil, `isTouched()` |
| `fs` | `readFile(path)`, `writeFile(path, data)`, `exists(path)`, `listFiles(path)`, `listDirs(path)` |
| `sys` | `getMillis()`, `delay(ms)`, `back()`, `launch(path, arg)`, `replace(path, arg)`, `getAppName()`, `setAppName(name)`, `getMemory()` -> `free,total,largest`, `isClockSynced()`, `setTickInterval(ms)` |
| `settings` | `getRotation()`, `setRotation(deg)`, `getTheme()`, `setTheme(name)`, `getTimezone()`, `setTimezone(tz)`, `setCalibration(x0,y0,x1,y1)` |
| `screen` | `getWidth()`, `getHeight()`, `clear(c)`, `fillRect`, `drawRect`, `drawLine`, `drawPixel`, `drawCircle`, `fillCircle`, `roundRect`, `fillPolygon`, `drawBmp`, `drawText`, `getTextWidth`, `getFontHeight`, `getRotation()`, `setRotation(deg)`, `getTheme()`, `setTheme(name)`, `color(r,g,b)` |
| `tree` | the widget tree the panel lays out and paints; apps reach it through `ui` |
| `touch` | `getPoint()` -> `x,y` or nil, `getRawPoint()` -> raw ADC `x,y` or nil, `isTouched()`, `setCalibration(x0,y0,x1,y1)` |
| `fs` | `exists`, `fileSize`, `listFiles`, `listDirs`, `mkdir`, `readFile`, `readLineAt`, `remove`, `removeTree`, `rename`, `writeFile` |
| `sys` | `getMillis()`, `startApp(path, args)`, `getMemory()` -> `free,total,largest`, `isClockSynced()`, `getTimezone()`, `setTimezone(tz)`, `getAPIVersion()`, `hasFeature(name)` |
| `timer` | `after(ms, fn)`, `every(ms, fn)`, `cancel(id)` |
| `wifi` | `scan()` -> `{ssid,rssi,secure}[]`, `connect(ssid,password)`, `getStatus()` -> `{state,ssid,ip,rssi}`, `getLocalIP()`, `isConnected()`, `disconnect()`, `forget()` |
| `log` | `debug(msg)`, `info(msg)`, `error(msg)` (serial) |
| `http`, `ble`, `log` | requests, BLE/GATT, and `debug`/`info`/`error` to serial |
| `cjson` | `require "cjson"`: `encode(value)`, `decode(text)`, `null` |
| `nav` | `require "nav"`: `launch(route, arg)`, `replace(route, arg)`, `back()`, `canGoBack()`, `getTitle()`, `setTitle(text)`, `getRoute()`, `getArg()`, `getDataPath()` |
Accessors are `getName` / `setName` / `isName`; bare names are actions (`gui.fillRect`)
or pure conversions (`gui.color`, `http.urlencode`). `settings.*` is persisted user
intent; `gui.getRotation()` is the frame actually being drawn, which differs while an
app rotates the panel transiently.
Accessors are `getName` / `setName` / `isName`; bare names are actions (`screen.fillRect`)
or pure conversions (`screen.color`, `http.urlencode`). A setter that needs a follow-up
call is a bug in the setter: `screen.setRotation()` rotates the panel, saves the choice
and re-clips. The one exception is the theme, since C cannot reload the Lua palette —
apps call `ui.setTheme()`, which writes through `screen.setTheme()` and then repaints.
Colors are RGB565 integers; build them with `gui.color(r, g, b)`.
Colors are RGB565 integers; build them with `screen.color(r, g, b)`.
## Settings
@@ -88,19 +103,25 @@ return {
```
Missing file means the built-in defaults are used. `apps/Settings` walks two
crosshairs and saves the result via `settings.setCalibration()`, cycles `rotation`
crosshairs and saves the result via `touch.setCalibration()`, cycles `rotation`
through 0, 90, 180 and 270 degrees, and scans/selects Wi-Fi networks with an
on-screen password keyboard. A saved network reconnects at boot.
Settings live in C++ because the firmware reads rotation and calibration before any
`lua_State` exists, and calibration again on every touch. Lua reaches them through the
`screen` and `touch` bindings, so there is one writer.
Wi-Fi credentials are Lua-escaped but stored as plaintext on the SD card. Treat the
card like any other device containing a saved password.
Rotation never needs a recalibration: calibration is stored in the panel's
rotation-0 frame (320x480 raw ADC space) and the current rotation is applied
afterwards, so `settings.setRotation()` is safe at any time. `gui.setRotation(degrees)`
changes only the current frame; the firmware restores the saved rotation when an
app exits. The glass itself is always portrait, so a rotated UI is drawn
sideways on it.
afterwards, so `screen.setRotation(degrees)` is safe at any time. It rotates the panel
and persists the choice in one call; an app that rotates transiently for its own purposes
puts the old value back, which is what touch calibration does. The glass itself is always
portrait, so a rotated UI is drawn sideways on it.
## Tests
```sh
make test # everything below, non-zero on the first failure
@@ -109,9 +130,14 @@ make test # everything below, non-zero on the first failure
| Test | Covers |
|---|---|
| `test/round_rect_test.cpp` | corner geometry, coverage and RGB565 blending |
| `test/ui_layout.lua` | layout rects, hit testing, press capture |
| `test/ui_theme.lua` | palette derivation and inheritance |
| `test/settings_calibration.lua` | calibration maths, menu and wifi flows |
| `test/keyboard.lua` | key geometry and what a tap enters |
| `test/settings_calibration.lua` | calibration maths, menu and rotation flows |
| `test/settings_busy.lua` | the settings app while a scan is in flight |
| `test/statusbar_dirty.lua` | per-field repaints, the back control, fullscreen |
| `test/ble.lua` | BLE scanning through the settings app |
`make test` also runs the submodule's suite, which owns layout geometry
(`ui_layout_test.cpp`), the shared modules and the generated API check.
The Lua tests run against `test/fake_device.lua`, the one place the binding surface is
stubbed, and `make test` refuses to run on anything but Lua 5.4 — the version the
@@ -119,7 +145,7 @@ firmware vendors, so the tests cannot pass on a dialect the device will not run.
## Drawing rounded surfaces
`gui.roundRect(x, y, w, h, radius, bg, top, bottom, border)` draws a whole surface in
`screen.roundRect(x, y, w, h, radius, bg, top, bottom, border)` draws a whole surface in
one pass. Fill and border come from the same signed distance field, so they cannot
disagree at the corners, and edge pixels are anti-aliased by coverage. `top`/`bottom`
are gradient stops (pass one for a solid, or `nil` for no fill) and `border` may be
@@ -129,7 +155,7 @@ pixels blend into — pass the surface the shape sits on, not the shape's own fi
The geometry lives in `src/gfx/round_rect.h`, free of Arduino headers, because the
previous hand-rolled corner arc was wrong in a way only a pixel test would catch.
## UI toolkit (`/lib/ui.lua`)
## UI toolkit (`/.lua/lib/ui.lua`)
Apps describe nesting and sizes fall out, borrowing CSS block flow and the box model
without the cascade:
@@ -137,35 +163,43 @@ without the cascade:
```lua
local ui = require("ui")
local screen = ui.screen(ui.box{pad = 12, gap = 8, color = BLACK, bg = WHITE,
ui.text("settings"),
ui.button{label = "calibrate touch", on_press = calibrate},
})
local M = {}
function draw() screen:draw() end
function on_touch_down(x, y) screen:down(x, y) end
function on_touch_up(x, y) screen:up(x, y) end
function M.node()
return ui.box{pad = 12, gap = 8,
ui.text("settings"),
ui.button{label = "calibrate touch", on_click = calibrate},
}
end
return M
```
`main.lua` mounts that with `ui.mount()`, so nothing here dispatches touches or paints
frames. A screen changes by changing state and calling `ui.rebuild()`, which runs `node()`
again; building a node outside a rebuild is refused, because it would reset the arena
under the screen already on the panel.
Sizes are pixels (>= 1), a fraction of the parent's content box (< 1), `"fill"` for all
of it, or `"auto"` (the default on the flow axis; the cross axis fills the parent).
Boxes take `pad`, `gap`, `row`, `align`, `justify`, `border`, `capture`, and `at` for
absolute placement. `color`, `bg`, `radius`, `gradient` and the press palette inherit
from the root, so a theme is set once.
absolute placement. An app sizing itself to its frame reads `ui.frame()` rather than
`screen.getHeight()`, because layout has not run while a builder is running and the panel
is not the box the app was given — the status bar takes the difference.
Dialogs are not a layer the toolkit manages. A dialog is a node the app includes when
its state calls for one, placed absolutely so it covers the flow rather than joining
it, and dismissed by rebuilding without it:
```lua
local function build()
function M.node()
local content = ui.box{pad = 12, gap = 8, rows()}
if not confirming then return ui.screen(ui.box{content}) end
return ui.screen(ui.box{content, ui.confirm{
if not confirming then return ui.box{content} end
return ui.box{content, ui.confirm{
title = "forget network?", ok = "forget",
on_ok = function() wifi.forget(); confirming = false; build() end,
on_cancel = function() confirming = false; build() end,
}})
on_ok = function() wifi.forget(); confirming = false; ui.rebuild() end,
on_cancel = function() confirming = false; ui.rebuild() end,
}}
end
```
@@ -183,16 +217,20 @@ anti-aliased edges blend into `surface`. A rounded card that blends into its own
leaves square corners, which only shows once something behind it is a different color.
The toolkit owns press capture (release outside cancels), an 80 ms minimum pressed
duration, touch slop, and per-component dirty tracking. A custom painter can add
`paint_part(node, id)` and call `node:invalidatePart(id)` to repaint only a self-contained
region; a normal `invalidate()` still supersedes queued regions. For partial pressed
feedback, `on_down` returns the region ID and `invalidate_press(node, id)` invalidates it;
the screen retains that ID through the 80 ms hold. Any table with `measure`, `place`, `draw`
and `hit` drops into the tree, so custom components need no buy-in.
duration, touch slop, and per-node dirty tracking. A node is a 16-byte struct in a flat
arena rather than a Lua table, so `ui.button{...}` hands back an integer id and anything an
app wants to hang off a node lives in a table keyed by that id. Update a live node with
`ui.setText(id, text)` or `ui.invalidate(id)`; a full screen change is `ui.rebuild()`.
`ui.custom{paint = ...}` paints itself through the `screen` bindings, which is the seam
between composition (Lua on the card) and primitives (C++ and a reflash). A painter that
draws its own press feedback sets `press_style = false` so pressing one part does not
repaint the whole node, and repaints one region from `on_down`, restoring it in
`on_unpress` after the 80 ms hold.
Not implemented: scrolling, so a list longer than the screen is unreachable.
`/lib/keyboard.lua` provides a single-node, staggered QWERTY keyboard so its keys do not
`/.lua/lib/keyboard.lua` provides a single-node, staggered QWERTY keyboard so its keys do not
retain dozens of component tables. It owns shift, number/symbol pages, backspace, per-key
pressed feedback, and release-over-the-same-key activation:
@@ -205,39 +243,44 @@ keyboard.new{
}
```
## Themes (`/lib/theme.lua`)
## Themes
A theme is three seed colors; `ui.lua` derives the rest, so adding a component never
means editing a theme, and a theme cannot state its own contrast wrongly:
A theme is three seed colors and a radius, declared in `ui.lua`; the rest is derived, so
adding a component never means editing a theme, and a theme cannot state its own contrast
wrongly:
```lua
return {
light = {bg = {255, 255, 255}, fg = {0, 0, 0}, accent = {0, 120, 255}},
midnight = {bg = {12, 14, 30}, fg = {220, 225, 240}, accent = {255, 120, 0}},
}
light = { background = {255, 255, 255}, color = {0, 0, 0}, accent = {0, 120, 255}, radius = 6 },
dark = { background = {18, 18, 20}, color = {235, 235, 235}, accent = {166, 118, 255}, radius = 6 },
```
| Role | Derivation |
|---|---|
| `bg`, `fg`, `accent` | the seeds |
| `muted` | `fg` blended 45% toward `bg`, for secondary text |
| `accent_fg` | black or white, whichever the accent's luminance demands |
| `face`, `face_pressed` | button gradients, from `bg` and `accent` |
| `radius` | 6 |
| `background`, `color`, `accent` | the seeds |
| `muted` | `color` blended 45% toward `background`, for secondary text |
| `accentColor` | black or white, whichever the accent's luminance demands |
| `face`, `pressedFace` | button fills, from `background` and `accent` |
| `radius` | 6, or 0 for `mono` |
Any derived role can be pinned in the theme file (`radius = 0`, `muted = {120,120,120}`).
The settings app cycles through the names it finds, and stores only the name.
The settings app cycles through the names it finds and stores only the name. Applying one
is `ui.setTheme()`, which persists through `screen.setTheme()`, rebuilds the palette and
repaints — apps never call `screen.setTheme()` directly.
Components get the theme by inheritance, so a themed app names no colors at all.
The division is: **inheritance carries context** (`self.color`, `self.bg` — what surface
am I on), while **`ui.theme` carries constants** (`ui.theme.accent`). Read the theme
directly only when drawing outside the component tree, like the calibration crosshair.
An explicit value on any node still wins, which is how a component deviates:
The division is: **inheritance carries context** (`color`, `bg` — what surface am I on),
while **`ui.theme` carries constants** (`ui.theme.accent`). Read the theme directly only
when drawing outside the component tree, like the calibration crosshair. An explicit value
on any node still wins, which is how a component deviates:
```lua
ui.button{label = "delete", press_bg = DANGER}
ui.button{label = "delete", pressedFace = DANGER}
```
The style roles a node may set are `color`, `fill`, `border`, `face`, `pressedFace`,
`pressedColor`, `focusColor`, `radius`, `font` and `textStyle`. They are sparse and
inherited: a role a node does not set is answered by the nearest ancestor that does, so a
node naming no colours costs nothing.
The C++ Lua-error and SD-failure screens stay hardcoded high contrast, since they can
fire when the settings that name the theme are themselves unreadable.
-100
View File
@@ -1,100 +0,0 @@
# Lua API parity with crosspoint-reader
Both firmwares expose a Lua API to apps on an SD card, and both are ESP32 devices, so a
script that only touches files and the network should behave the same on either. This
records where they agree, where they differ **for a reason**, and where they differ
because nobody noticed. The last group is a bug list, not a design.
Compared against crosspoint-reader at `src/util/lua/LuaBindings*.cpp`.
## Identical
`fs.listDirs`, `fs.listFiles`, `fs.exists`, `fs.readFile`, `fs.writeFile`,
`gui.getWidth`, `gui.getHeight`, `gui.fillRect`, `gui.drawRect`, `gui.drawLine`,
`sys.getMillis`, `sys.delay`, `log.debug/info/error`, the whole `http` and `ble` tables,
`sys.setTickInterval`, `wifi.getStatus`, `wifi.isConnected`, `wifi.getLocalIP`, and the
`draw()` / `on_tick()` callbacks. `init()` is required in both, so a misspelled entry
point is an error rather than an app that quietly draws nothing.
`wifi.getStatus()` returns `{state, ssid, ip, rssi}` in both. crosspoint returned a bare
string until this was reconciled; a string had nowhere to put the address and signal
strength a status screen wants. Its `state` vocabulary is a subset: crosspoint reports
`disconnected`, `connecting`, `connected` and `failed`, while this firmware adds
`not_found`, because it joins one named network rather than walking a credential list.
## Deliberate differences
| Area | crosspoint-reader | slate32 | Why |
|---|---|---|---|
| App lifecycle | `sys.exit()`, `init()` | `sys.back()`, `sys.launch/replace(path, arg)`, `init(arg)` | slate32 apps form a reloadable route stack; only the path and optional string cross between Lua states. |
| Input | `input.wasPressed(button)` and friends, 8 named buttons | `input.getTouch`, `getRawTouch`, `touched` | Different hardware. A touch panel has no button names and a button device has no coordinates. |
| Drawing | `gui.drawText(font, x, y, text, color, style)`, `getTextWidth(font, text)` | `gui.drawText(text, x, y, color, bg)`, `gui.getTextWidth(text)` | crosspoint ships several fonts; this firmware has one built-in font scaled by `gui.setTextSize(n)`, and needs an opaque background colour because the panel is not e-ink. |
| Refresh | `gui.refresh(mode)`, `REFRESH_FULL/HALF/FAST` | none | An LCD has no waveform modes. |
| Colour | `COLOR_*` constants, 4 grey levels | `gui.color(r, g, b)` returning RGB565 | 16-bit colour has too many values to enumerate. |
| Shapes | `drawRoundedRect` + `fillRoundedRect` | one `gui.roundRect(...)` with gradient and border | Fill and border derive from a single distance field, so their edges cannot disagree. |
| Themes | none | `settings.getTheme/setTheme`, `/lib/theme.lua` | Colour panel. |
| Rotation | `gui.setOrientation("portrait")` | `settings.setRotation(degrees)` persisted, `gui.setRotation(degrees)` for one frame | This device stores rotation in settings and remaps touch to match. |
| Clock | nothing exposed; UTC offset is a C++ setting | `sys.isClockSynced`, `settings.getTimezone/setTimezone` with POSIX TZ rules | Timezone here is a stored rule, so `os.date()` returns local time with DST handled by libc. |
| Launching | launcher is C++ | `sys.launch(path)`, home is a Lua app | The launcher is just another app here, named `home`. |
| Modules | single-file apps; `require` unusable | `require` works, `package.searchers` reads the SD card, `/lib` on the path | Shared code such as `ui.lua` needs it. **crosspoint should adopt this.** |
| TLS memory | `TlsScratchLoan` lends the framebuffer to wolfSSL | none | crosspoint is heap-starved; this device has ~280KB free. |
## Accidental differences — drift, not design
Each of these is the same concept spelled two ways. Fixing them means changing one repo.
| Concern | crosspoint-reader | slate32 | Suggested resolution |
|---|---|---|---|
| `wifi.connect()` | no arguments, uses stored credentials | `(ssid, password)`, saves them | Both are wanted: a no-argument reconnect and an explicit join. |
| `fs.readFile` cap | 50000 bytes | 65536 bytes | Arbitrary in both. |
| `fs` mutation | `mkdir`, `rename`, `remove`, `removeTree`, plus a path-safety check rejecting `..` | absent | **This repo is missing them, including the traversal guard.** |
| `fs.fileSize`, `fs.readLineAt` | present | absent | Worth porting; `readLineAt` exists for paging large files. |
| Timers | `timer.after/every/cancel` + `on_timer(id)` | absent | Worth porting. |
## The `http` table
Signatures match crosspoint exactly, so scripts port unchanged:
```lua
http.get(url, headers?) -> body|nil, status
http.head(url, headers?) -> body|nil, status
http.delete(url, headers?) -> body|nil, status
http.post(url, body?, headers?) -> body|nil, status
http.patch(url, body?, headers?) -> body|nil, status
http.download(url, dest, options) -> bytesWritten | nil, error
http.urlencode(input) -> string
```
`status` is `-1` when the request never left the device. Bodies are capped at 50000
bytes, matching crosspoint, and a larger response yields `nil` with the real status.
`http.download` requires HTTPS, requires `maxBytes`, accepts `expectedSize` and
`sha256`, rejects unknown option keys, and deletes the file if any check fails.
Two behaviours are **deliberately not** copied:
1. **No argument shifting.** crosspoint (`LuaBindingsNet.cpp:209`) treats a string in
argument 2 of `get`/`head`/`delete` as a request *body*, so a mistyped headers table
becomes a silent protocol error. Here that raises.
2. **Certificates are verified.** crosspoint calls `setInsecure()` for every Lua request
and for `http.download`, so traffic is encrypted but unauthenticated — including the
path a firmware update would use. This firmware verifies against the root bundle
already embedded in the framework (`_binary_x509_crt_bundle_start`, ~62KB, linked
only when referenced). Verified in the emulator: `https://expired.badssl.com`
returns status `-1`, and a matching `sha256` accepts while a wrong one deletes the
file.
The cost of matching the signatures is that there is nowhere to put a per-request CA or
an insecure escape hatch, so TLS policy is device-wide and scripts cannot weaken it.
That is the right trade for a device that flashes itself.
## Stub generation
`scripts/gen_lua_stubs.py` emits `stubs/slate32.lua` in the same LuaLS `---@meta`
format crosspoint uses for `data/lua/crosspoint.lua`, so one editor setup covers both.
The parser differs: crosspoint annotates each C function and reads `addFunction(...)`
calls, while this repo annotates the `luaL_Reg` table so a module's documentation stays
contiguous with its registration.
`make test` runs `--check`. crosspoint's generator has no such wiring, so its stub can
drift from its bindings silently; that is worth copying back.
+4 -1
View File
@@ -28,7 +28,10 @@ build_flags =
; TFT_BACKLIGHT_ON the panel renders correctly and stays dark.
-DTFT_BL=27
-DTFT_BACKLIGHT_ON=HIGH
-DSPI_FREQUENCY=40000000
; 80MHz is what the panel vendor's own ESP-IDF/LVGL port runs this ST7796 at, and the bus
; is the floor on a full-pane repaint: 320x436x16bpp is 279KB, which is 56ms at 40MHz and
; 28ms at 80. Drop back to 40000000 if a module shows pixel garbage.
-DSPI_FREQUENCY=80000000
-DSPI_TOUCH_FREQUENCY=2500000
-DLOAD_GLCD
+38 -15
View File
@@ -1,25 +1,48 @@
local ui = require "ui"
local FONT = gui.FONT_UI
local FONT = screen.FONT_UI
---@class HelloApp : SlateApp, TouchHandlers
local M = {}
local seconds = 0
-- Draws straight to the panel rather than through components, so it reads the theme.
local theme = ui.theme
local uptime
function init()
timer.every(1000, tick)
gui.clear(theme.background)
gui.drawText(FONT, 10, 10, "hello from sd card", theme.color, nil, theme.background)
gui.drawText(FONT, 10, 30, "touch the screen", theme.muted, nil, theme.background)
log.info("hello app started, screen " .. gui.getWidth() .. "x" .. gui.getHeight())
local function text()
return "uptime: " .. seconds .. "s"
end
function tick()
seconds = seconds + 1
gui.fillRect(10, 50, 120, 20, theme.background)
gui.drawText(FONT, 10, 50, "uptime: " .. seconds .. "s", theme.color, nil, theme.background)
function M.init()
timer.every(1000, function()
seconds = seconds + 1
ui.setText(uptime, text())
end)
log.info("hello app started, screen " .. screen.getWidth() .. "x" .. screen.getHeight())
end
function on_touch(x, y)
function M.node()
-- A fixed slot for the clock, because a node keeps the box it was measured with and the
-- text grows a digit at a time.
uptime = ui.text(text(), {
w = screen.getTextWidth(FONT, "uptime: 00000s"),
h = screen.getFontHeight(FONT),
font = FONT,
})
return ui.box {
w = "fill",
h = "fill",
pad = 10,
gap = 20,
ui.text("hello from sd card", { font = FONT }),
ui.text("touch the screen", { font = FONT, color = ui.theme.muted }),
uptime,
}
end
-- Painted straight to the panel rather than as nodes: the dots are a scribble, and the
-- tree would need one node each to remember them.
function M.onTouch(x, y)
log.info("touch at " .. x .. ", " .. y)
gui.fillCircle(x, y, 6, theme.accent)
screen.fillCircle(x, y, 6, ui.theme.accent)
end
return M
+13 -20
View File
@@ -1,8 +1,11 @@
local nav = require "nav"
local ui = require "ui"
local PAD, GAP = 12, 8
local screen
---@class HomeApp : SlateApp
local M = {}
local names = {}
local function card(name, side)
return ui.button {
@@ -11,21 +14,23 @@ local function card(name, side)
justify = "center",
align = "center",
on_click = function()
sys.launch(name)
nav.launch(name)
end,
ui.label(name, { font = gui.FONT_UI, fit = side - 16 }),
ui.label(name, { font = screen.FONT_UI, fit = side - 16 }),
}
end
function init()
local names = {}
function M.init()
for _, name in ipairs(fs.listDirs "/.lua/apps") do
if name ~= "Home" then
names[#names + 1] = name
end
end
table.sort(names)
log.info "home ready"
end
function M.node()
local side, cols = ui.cardSide(math.max(#names, 1), PAD, GAP)
-- Rows are filled before ui.box() sees them: the constructor moves a spec's array part
@@ -42,7 +47,7 @@ function init()
-- Centred left to right as a unit, still top aligned: the gaps inside the grid are
-- fixed, so the leftover width belongs beside the block, not to its last column.
local items = { pad = PAD, gap = GAP, w = "fill", align = "center" }
local items = { pad = PAD, gap = GAP, w = "fill", h = "fill", align = "center" }
local gridW = cols * side + (cols - 1) * GAP
for _, row in ipairs(rows) do
row.row, row.gap, row.w = true, GAP, gridW
@@ -51,19 +56,7 @@ function init()
if #rows == 0 then
items[#items + 1] = ui.text("no apps in /.lua/apps", { color = ui.theme.muted })
end
screen = ui.screen(ui.box(items))
log.info "home ready"
return ui.box(items)
end
function draw()
screen:draw()
end
function on_touch_down(x, y)
screen:down(x, y)
end
function on_touch_up(x, y)
screen:up(x, y)
end
return M
+18 -17
View File
@@ -1,15 +1,25 @@
local ui = require "ui"
local keyboard = require "keyboard"
local keyboard = require "ui.keyboard"
local screen, valueLabel
---@class KeyboardApp : SlateApp
local M = {}
local valueLabel
local shown = "type something"
local function show(prefix, value)
ui.setText(valueLabel, value == "" and "type something" or prefix .. value)
shown = value == "" and "type something" or prefix .. value
ui.setText(valueLabel, shown)
end
function init()
valueLabel = ui.text("type something", { w = "fill" })
screen = ui.screen(ui.box {
function M.init()
log.info "keyboard test ready"
end
function M.node()
valueLabel = ui.text(shown, { w = "fill" })
return ui.box {
w = "fill",
h = "fill",
pad = 8,
gap = 5,
valueLabel,
@@ -22,16 +32,7 @@ function init()
show("submitted: ", value)
end,
},
})
log.info "keyboard test ready"
}
end
function draw()
screen:draw()
end
function on_touch_down(x, y)
screen:down(x, y)
end
function on_touch_up(x, y)
screen:up(x, y)
end
return M
+49 -26
View File
@@ -1,50 +1,73 @@
local ui = require "ui"
local FONT = gui.FONT_UI
-- No components: a canvas has one gesture and every pixel is content, so the app takes
-- the touch callbacks raw. It wants the firmware's noise threshold and no gesture slop --
-- a three pixel stroke is a stroke, not a mis-tap.
local theme = ui.theme
local CLEAR = { x = 0, y = 0, w = 64, h = 28 }
-- One node for the whole canvas: every pixel is content, so the app takes the touch
-- callbacks raw rather than asking the tree what was hit. It wants the firmware's noise
-- threshold and no gesture slop -- a three pixel stroke is a stroke, not a mis-tap.
local BRUSH = 2
---@class PaintApp : SlateApp, TouchHandlers
local M = {}
local canvas, area
local lastX, lastY
local function inClear(x, y)
return x < CLEAR.w and y < CLEAR.h
local function inside(x, y)
return area and x >= area.x and x < area.x + area.w and y >= area.y and y < area.y + area.h
end
local function clear()
gui.clear(theme.background)
gui.roundRect(CLEAR.x, CLEAR.y, CLEAR.w, CLEAR.h, theme.radius, theme.background, theme.face, nil, theme.color)
gui.drawText(FONT, 14, 10, "clear", theme.color)
end
function init()
clear()
function M.init()
log.info "paint ready"
end
function on_touch_down(x, y)
if inClear(x, y) then
lastX = nil
return clear()
function M.node()
-- The painter runs on every repaint, and a repaint is what clearing means here: the
-- tree fills the box with the background before calling it, so this only has to
-- remember where the box landed.
canvas = ui.custom {
w = "fill",
h = "fill",
paint = function(_, x, y, w, h)
area = { x = x, y = y, w = w, h = h }
end,
}
return ui.box {
w = "fill",
h = "fill",
ui.box {
row = true,
pad = 8,
ui.button {
label = "clear",
on_click = function()
lastX = nil
ui.invalidate(canvas)
end,
},
},
canvas,
}
end
function M.onTouchDown(x, y)
if not inside(x, y) then
return
end
lastX, lastY = x, y
gui.fillCircle(x, y, BRUSH, theme.accent)
screen.fillCircle(x, y, BRUSH, ui.theme.accent)
end
-- Strokes are joined with a line because the poll rate, not the finger, decides the gap:
-- a fast swipe reports points tens of pixels apart and dots alone would look dotted.
function on_touch_move(x, y)
if not lastX then
function M.onTouchMove(x, y)
if not lastX or not inside(x, y) then
return
end
gui.drawLine(lastX, lastY, x, y, theme.accent)
gui.fillCircle(x, y, BRUSH, theme.accent)
screen.drawLine(lastX, lastY, x, y, ui.theme.accent)
screen.fillCircle(x, y, BRUSH, ui.theme.accent)
lastX, lastY = x, y
end
function on_touch_up()
function M.onTouchUp()
lastX = nil
end
return M
+37
View File
@@ -0,0 +1,37 @@
local ui = require "ui"
-- Rows are ordinary nodes inside a scrolling box, so this app draws nothing and handles no
-- touch: the tree pans on a drag, coasts on a flick and clips the subtree to the box. Both
-- axes scroll -- the rows are wider than the pane -- so a diagonal drag moves diagonally.
local ROWS = 60
local ROW_H = 64
local ROW_W = 420 -- wider than the pane, so there is something to pan across
local GAP = 8
---@class ScrollApp : SlateApp
local M = {}
function M.init()
log.info "scroll demo ready"
end
function M.node()
-- Sized to the frame, not "fill": fill resolves against the whole panel, and the pane the
-- app was given is shorter by the status bar. The difference is the scroll range.
local frameW, frameH = ui.frame()
local rows = { gap = GAP, pad = GAP, scrollX = true, scrollY = true, w = frameW, h = frameH }
for i = 1, ROWS do
rows[#rows + 1] = ui.box {
w = ROW_W,
h = ROW_H - GAP,
justify = "center",
align = "center",
background = (i % 2 == 1) and ui.theme.face or ui.theme.background,
border = ui.theme.muted,
ui.label("Row " .. i, { font = screen.FONT_LARGE }),
}
end
return ui.box(rows)
end
return M
+155
View File
@@ -0,0 +1,155 @@
local ui = require "ui"
local nav = require "nav"
---@class BleApp : SlateApp
local M = {}
local mode = "ble"
local bleMessage, busyText
local bleDevices = {}
local bleScanRequested, bleConnectRequested = false, false
local selectedBleDevice
local bleDeviceOf = {}
local function show(next)
mode = next
ui.rebuild()
end
local function backTo(next)
return function()
show(next)
end
end
local function busy(text)
busyText = text
show "busy"
end
local function enableBle()
local ok, err = ble.init "Slate32 BLE"
bleMessage = ok and "BLE on" or err
show "ble"
end
local function disableBle()
ble.deinit()
bleMessage = "BLE off"
show "ble"
end
local function disconnectBle()
ble.disconnect()
bleMessage = "BLE disconnected"
show "ble"
end
local function startBleAdvertising()
local ok, err = ble.startAdvertising "Slate32 BLE"
bleMessage = ok and "BLE advertising" or err
show "ble"
end
local function stopBleAdvertising()
ble.stopAdvertising()
bleMessage = "BLE advertising stopped"
show "ble"
end
local function requestBleScan()
bleScanRequested = true
busy "scanning BLE..."
end
local function selectBleDevice(id)
selectedBleDevice = bleDeviceOf[id]
bleConnectRequested = true
busy "connecting BLE..."
end
local function bleScreen()
local initialized = ble.isInitialized()
local items = { pad = 12, gap = 7, ui.text "BLE", ui.text(initialized and "on" or "off") }
if bleMessage then
items[#items + 1] = ui.text(bleMessage, { font = screen.FONT_SMALL })
end
if initialized then
items[#items + 1] = ui.button { label = "scan devices", on_click = requestBleScan }
items[#items + 1] = ui.button { label = "start advertising", on_click = startBleAdvertising }
items[#items + 1] = ui.button { label = "stop advertising", on_click = stopBleAdvertising }
if ble.isConnected() then
items[#items + 1] = ui.button { label = "disconnect", on_click = disconnectBle }
end
items[#items + 1] = ui.button { label = "turn off", on_click = disableBle }
else
items[#items + 1] = ui.button { label = "turn on", on_click = enableBle }
end
return items
end
local function bleDevicesScreen()
local items = { pad = 12, gap = 6, ui.text "BLE devices" }
bleDeviceOf = {}
for index = 1, math.min(#bleDevices, 6) do
local device = bleDevices[index]
local name = device.name ~= "" and device.name or device.address
if #name > 24 then
name = name:sub(1, 24)
end
local button = ui.button {
label = name .. " " .. device.rssi,
on_click = selectBleDevice,
}
bleDeviceOf[button] = device
items[#items + 1] = button
end
if #bleDevices == 0 then
items[#items + 1] = ui.text "no devices found"
end
items[#items + 1] = ui.button { label = "rescan", on_click = requestBleScan }
items[#items + 1] = ui.button { label = "back", on_click = backTo "ble" }
return items
end
local SCREENS = {
ble = bleScreen,
ble_devices = bleDevicesScreen,
busy = function()
return { w = "fill", h = "fill", justify = "center", align = "center", ui.label(busyText) }
end,
}
function M.node()
return ui.box { w = "fill", h = "fill", ui.box(SCREENS[mode]()) }
end
function M.init()
nav.setTitle "BLE"
timer.every(50, M.tick)
log.info "BLE ready"
end
function M.tick()
if bleScanRequested then
bleScanRequested = false
local devices, err = ble.scan(3000)
if devices then
log.info("BLE scan found " .. #devices .. " devices")
bleDevices = devices
show "ble_devices"
else
bleMessage = err
show "ble"
end
return
end
if bleConnectRequested then
bleConnectRequested = false
local ok, err = ble.connect(selectedBleDevice.address)
bleMessage = ok and "BLE connected" or err
show "ble"
end
end
return M
@@ -0,0 +1,85 @@
local ui = require "ui"
local nav = require "nav"
local statusbar = require "ui.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 "ui.optionlist"
-- setRotation persists and re-clips the panel itself, so applying is the whole action.
return optionlist.app {
title = "Rotation",
options = {
{ label = "0 degrees", value = 0 },
{ label = "90 degrees", value = 90 },
{ label = "180 degrees", value = 180 },
{ label = "270 degrees", value = 270 },
},
current = function()
return screen.getRotation()
end,
apply = function(value)
return screen.setRotation(value)
end,
}
+16
View File
@@ -0,0 +1,16 @@
local ui = require "ui"
local optionlist = require "ui.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 "ui.optionlist"
local zones = require "timezones"
-- setTimezone applies the TZ itself, so a saved zone missing from the list just leaves
-- nothing marked rather than pretending to be the first entry.
local options = {}
for _, zone in ipairs(zones) do
options[#options + 1] = { label = zone.name, value = zone.tz }
end
return optionlist.app {
title = "Timezone",
options = options,
current = sys.getTimezone,
apply = sys.setTimezone,
}
+264
View File
@@ -0,0 +1,264 @@
local ui = require "ui"
local nav = require "nav"
---@class WifiApp : SlateApp
local M = {}
-- Every screen is a value of mode; node() builds whichever is current, and nothing is
-- retained between them. This is its own app now, so the status screen's back leaves to
-- the Settings menu (nav.back) while the deeper screens fall back to the status one.
local mode = "wifi"
local message, busyText, dialog
local networks = {}
local scanRequested, keyboardRequested = false, false
local selectedNetwork, password = nil, ""
local passwordLabel
local networkOf = {}
local function show(next)
mode = next
ui.rebuild()
end
local function backTo(next)
return function()
show(next)
end
end
local function wifiValue(status)
return status.state == "connected" and "on" or "off"
end
-- Announced rather than left to draw(): the work these stand in for blocks the loop, and
-- tick() runs before draw in the same pass, so the panel would sit on the background the
-- rebuild cleared it to until the blocking call returned.
local function busy(text)
busyText = text
show "busy"
end
local function requestScan()
scanRequested = true
busy "scanning networks..."
end
local function forgetNetwork()
dialog = nil
message = wifi.forget() and "wifi forgotten" or "save failed"
show "wifi"
end
local function dismissDialog()
dialog = nil
ui.rebuild()
end
-- Destructive and one tap away from the row above it, which is what a confirm is for.
local function confirmForget()
dialog = "forget"
show "wifi"
end
local function connectSavedNetwork()
if not wifi.connect() then
message = "could not connect wifi"
return show "wifi"
end
show "connecting"
end
local function connectSelected()
if not wifi.connect(selectedNetwork.ssid, password) then
message = "could not save wifi"
return show "wifi"
end
show "connecting"
end
local function disconnectWifi()
wifi.disconnect()
message = "wifi off"
show "wifi"
end
local function chooseNetwork(network)
selectedNetwork, password = { ssid = network.ssid }, ""
if not network.secure then
return connectSelected()
end
keyboardRequested = true
busy "opening keyboard..."
end
local function selectNetwork(id)
chooseNetwork(networkOf[id])
end
local function wifiScreen()
local status = wifi.getStatus()
local items = { pad = 12, gap = 8, ui.text "wifi", ui.text(wifiValue(status)) }
if status.state == "connected" then
items[#items + 1] = ui.text("ip: " .. status.ip)
end
if status.ssid ~= "" then
items[#items + 1] = ui.button {
label = status.state == "connected" and "disconnect" or "connect",
on_click = status.state == "connected" and disconnectWifi or connectSavedNetwork,
}
end
items[#items + 1] = ui.button { label = "scan networks", on_click = requestScan }
if status.ssid ~= "" then
items[#items + 1] = ui.button { label = "forget network", on_click = confirmForget }
end
if message then
items[#items + 1] = ui.text(message)
end
return items
end
local function connectingScreen()
local to = selectedNetwork and (" to " .. selectedNetwork.ssid) or ""
return {
pad = 12,
gap = 8,
ui.text "wifi",
ui.text("connecting" .. to .. "..."),
ui.button { label = "back", on_click = backTo "wifi" },
}
end
local function networksScreen()
local byName = {}
for _, network in ipairs(networks) do
local current = byName[network.ssid]
if network.ssid ~= "" and (not current or network.rssi > current.rssi) then
byName[network.ssid] = network
end
end
local list = {}
for _, network in pairs(byName) do
list[#list + 1] = network
end
table.sort(list, function(a, b)
return a.rssi > b.rssi
end)
local items = { pad = 12, gap = 6, ui.text "wifi networks" }
networkOf = {}
for i = 1, math.min(#list, 6) do
local network = list[i]
local lock = network.secure and " *" or ""
local button = ui.button {
label = network.ssid .. lock .. " " .. network.rssi,
on_click = selectNetwork,
}
-- The network a card stands for, keyed by node id: a node carries nothing an app
-- puts on it, so anything hung on it lives in a Lua table instead.
networkOf[button] = network
items[#items + 1] = button
end
if #list == 0 then
items[#items + 1] = ui.text("no networks found", { color = ui.theme.muted })
end
items[#items + 1] = ui.button { label = "rescan", on_click = requestScan }
items[#items + 1] = ui.button { label = "back", on_click = backTo "wifi" }
return items
end
local function passwordScreen()
local keyboard = require "ui.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
+25 -510
View File
@@ -1,115 +1,14 @@
local ui = require "ui"
local FONT = gui.FONT_UI
-- The same module instance the firmware paints the bar with, so toggling fullscreen
-- through it drops the cache that would otherwise hide the repaint.
local statusbar = require "statusbar"
local INSET = 30
local MENU_PAD, MENU_GAP = 12, 8
local screen, message, passwordLabel, passwordRow
local mode = "menu"
local samples, pending, armed = {}, nil, false
local scanRequested, keyboardRequested = false, false
local bleScanRequested, bleConnectRequested = false, false
local selectedNetwork, password = nil, ""
local selectedBleDevice
local bleMessage
local timezoneCard
local buildMenu, buildWifi, buildNetworks, buildKeyboard, buildBle, buildBleDevices
local startCalibration, cycleRotation, updatePassword
-- Two inset targets give a raw-per-pixel slope; extrapolate it to the screen edges.
-- Exposed as a global so test/settings_calibration.lua can exercise it.
function 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
-- When set, a dialog node the current screen is rebuilt with. It is state like `mode`
-- and `message`, not something layered on afterwards, so no screen rebuild can lose it.
local dialog
local function themed(items)
-- The root carries no padding so a dialog can cover the whole panel; the padded box
-- is the content it covers. Colors come from the theme by inheritance.
local content = ui.box(items)
-- The scrim is a style on the box that holds the content, so it reaches the margins the
-- content box does not while the dialog above it keeps the lit palette.
if not dialog then
screen = ui.screen(ui.box { content })
return
end
screen = ui.screen(ui.box { ui.box { color = ui.theme.muted, face = ui.theme.background, content }, dialog })
end
local function target(n)
if n == 1 then
return INSET, INSET
end
return 320 - INSET, 480 - INSET
end
local function drawTarget(n)
local x, y = target(n)
-- Drawn outside the component tree, so this is the case that reads the theme directly.
local theme = ui.theme
gui.clear(theme.background)
gui.drawText(FONT, 10, 10, "tap the cross", theme.color, nil, theme.background)
gui.drawLine(x - 12, y, x + 12, y, theme.accent)
gui.drawLine(x, y - 12, x, y + 12, theme.accent)
end
local function finishCalibration()
local ok = settings.setCalibration(computeCalibration(samples[1], samples[2], 320, 480, INSET))
message = ok and "calibration saved" or "save failed"
mode = "menu"
gui.setRotation(settings.getRotation())
statusbar.setFullscreen(false)
buildMenu()
end
function startCalibration()
message = nil
mode = "calibrate"
samples, pending, armed = {}, nil, false
gui.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)
drawTarget(1)
end
function cycleRotation()
local ok = settings.setRotation((settings.getRotation() + 90) % 360)
message = ok and "rotation saved" or "save failed"
buildMenu()
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"
buildMenu()
end
local nav = require "nav"
local zones = require "timezones"
-- 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 MENU_PAD, MENU_GAP = 12, 8
---@class SettingsApp : SlateApp
local M = {}
local function zoneLabel()
local current = settings.getTimezone()
local current = sys.getTimezone()
for _, zone in ipairs(zones) do
if zone.tz == current then
return zone.name
@@ -118,61 +17,39 @@ local function zoneLabel()
return current
end
local function cycleTimezone()
local current = settings.getTimezone()
local next_index = 1
for index, zone in ipairs(zones) do
if zone.tz == current then
next_index = index % #zones + 1
end
end
local ok = settings.setTimezone(zones[next_index].tz)
ui.setText(timezoneValue, ok and zoneLabel() or "save failed")
ui.invalidate(timezoneCard)
end
local function wifiValue(status)
return status.state == "connected" and "on" or "off"
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,
ui.label(title, { font = gui.FONT_UI, fit = side - 12 }),
on_click = function()
nav.launch(route)
end,
ui.label(title, { font = screen.FONT_UI, fit = side - 12 }),
}
local valueLabel
if value then
valueLabel = ui.label(value, { font = gui.FONT_SMALL, fit = side - 12 })
spec[#spec + 1] = valueLabel
spec[#spec + 1] = ui.label(value, { font = screen.FONT_SMALL, fit = side - 12 })
end
local button = ui.button(spec)
if title == "Timezone" then
timezoneValue = valueLabel
end
return button
return ui.button(spec)
end
function buildMenu()
mode = "menu"
-- 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", settings.getRotation() .. " deg", cycleRotation),
card(side, "WiFi", wifiValue(wifi.getStatus()), buildWifi),
card(side, "BLE", ble.isInitialized() and "on" or "off", buildBle),
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"),
}
timezoneCard = cards[#cards]
-- 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
@@ -182,373 +59,11 @@ function buildMenu()
end
items[#items + 1] = ui.box(row)
end
if message then
items[#items + 1] = ui.text(message)
end
themed(items)
return ui.box(items)
end
-- Painted here rather than left to draw(): the work these announce blocks the loop, and
-- on_tick runs before draw in the same pass, so the panel would sit on the background
-- ui.screen() cleared it to until the blocking call returned.
local function busy(text)
themed { w = "fill", h = "fill", justify = "center", align = "center", ui.label(text) }
screen:draw()
end
local function requestScan()
mode = "scanning"
scanRequested = true
busy "scanning networks..."
end
local function forgetNetwork()
dialog = nil
message = wifi.forget() and "wifi forgotten" or "save failed"
buildWifi()
end
-- Destructive and one tap away from the row above it, which is what a confirm is for.
local function confirmForget()
dialog = ui.confirm {
title = "forget network?",
message = wifi.getStatus().ssid,
ok = "forget",
on_ok = forgetNetwork,
on_cancel = function()
dialog = nil
buildWifi()
end,
}
buildWifi()
end
local function connectSavedNetwork()
if not wifi.connect() then
message = "could not connect wifi"
buildWifi()
return
end
mode = "connecting"
themed {
pad = 12,
gap = 8,
ui.text "wifi",
ui.text "connecting...",
ui.button { label = "back", on_click = buildWifi },
}
end
local function disconnectWifi()
wifi.disconnect()
message = "wifi off"
buildWifi()
end
function buildWifi()
mode = "wifi"
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 = buildMenu }
themed(items)
end
local function connectSelected()
if not wifi.connect(selectedNetwork.ssid, password) then
message = "could not save wifi"
buildWifi()
return
end
mode = "connecting"
themed {
pad = 12,
gap = 8,
ui.text "wifi",
ui.text("connecting to " .. selectedNetwork.ssid .. "..."),
ui.button { label = "back", on_click = buildWifi },
}
end
local function chooseNetwork(network)
selectedNetwork, password = { ssid = network.ssid }, ""
if not network.secure then
connectSelected()
return
end
keyboardRequested = true
mode = "password"
busy "opening keyboard..."
end
-- 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.
local networkOf = {}
local function selectNetwork(id)
chooseNetwork(networkOf[id])
end
function buildNetworks(networks)
mode = "networks"
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" }
for i = 1, math.min(#list, 6) do
local network = list[i]
local lock = network.secure and " *" or ""
local card = ui.button {
label = network.ssid .. lock .. " " .. network.rssi,
on_click = selectNetwork,
}
networkOf[card] = network
items[#items + 1] = card
end
log.info("wifi scan found " .. #list .. " networks")
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 = buildWifi }
themed(items)
end
local bleDeviceOf = {}
local function enableBle()
local ok, err = ble.init "Slate32 BLE"
bleMessage = ok and "BLE on" or err
buildBle()
end
local function disableBle()
ble.deinit()
bleMessage = "BLE off"
buildBle()
end
local function disconnectBle()
ble.disconnect()
bleMessage = "BLE disconnected"
buildBle()
end
local function startBleAdvertising()
local ok, err = ble.startAdvertising "Slate32 BLE"
bleMessage = ok and "BLE advertising" or err
buildBle()
end
local function stopBleAdvertising()
ble.stopAdvertising()
bleMessage = "BLE advertising stopped"
buildBle()
end
local function requestBleScan()
mode = "ble_scanning"
bleScanRequested = true
busy "scanning BLE..."
end
local function selectBleDevice(id)
selectedBleDevice = bleDeviceOf[id]
mode = "ble_connecting"
bleConnectRequested = true
busy "connecting BLE..."
end
function buildBleDevices(devices)
mode = "ble_devices"
bleDeviceOf = {}
local items = { pad = 12, gap = 6, ui.text "BLE devices" }
for index = 1, math.min(#devices, 6) do
local device = devices[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 #devices == 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 = buildBle }
themed(items)
end
function buildBle()
mode = "ble"
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 = gui.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 = buildMenu }
themed(items)
end
function updatePassword(value)
password = value
ui.setText(passwordLabel, "password: " .. password)
ui.invalidate(passwordRow)
end
function buildKeyboard()
screen = nil
collectgarbage()
local keyboard = require "keyboard"
mode = "password"
passwordLabel = ui.text("password: " .. password)
passwordRow = ui.box { passwordLabel }
themed {
pad = 8,
gap = 5,
ui.text(selectedNetwork.ssid),
passwordRow,
keyboard.new {
value = password,
on_change = updatePassword,
on_submit = function(value)
password = value
connectSelected()
end,
},
}
local free, _, largest = sys.getMemory()
log.info("wifi keyboard ready free=" .. free .. " largest=" .. largest)
end
function init()
timer.every(50, tick) -- calibration samples the raw panel between draws
buildMenu()
function M.init()
log.info "settings ready"
end
function draw()
if mode ~= "calibrate" then
screen:draw()
end
end
function on_touch_down(x, y)
if mode ~= "calibrate" then
screen:down(x, y)
end
end
function on_touch_up(x, y)
if mode ~= "calibrate" then
screen:up(x, y)
end
end
function tick()
if keyboardRequested then
keyboardRequested = false
buildKeyboard()
return
end
if scanRequested then
scanRequested = false
buildNetworks(wifi.scan())
return
end
if bleScanRequested then
bleScanRequested = false
local devices, err = ble.scan(3000)
if devices then
log.info("BLE scan found " .. #devices .. " devices")
buildBleDevices(devices)
else
bleMessage = err
buildBle()
end
return
end
if bleConnectRequested then
bleConnectRequested = false
local ok, err = ble.connect(selectedBleDevice.address)
bleMessage = ok and "BLE connected" or err
buildBle()
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)
buildWifi()
elseif status.state == "failed" or status.state == "not_found" then
message = "connection " .. status.state
log.info("wifi connection " .. status.state)
buildWifi()
end
return
end
if mode ~= "calibrate" then
return
end
local rx, ry = input.getRawTouch()
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
drawTarget(2)
else
finishCalibration()
end
end
end
return M
-275
View File
@@ -1,275 +0,0 @@
local ui = require "ui"
local FONT = gui.FONT_UI
local M = {}
local KEY_W, KEY_H, KEY_GAP, ROW_GAP = 26, 30, 4, 5
local SIDE_W, MODE_W, SPACE_W, OK_W = 40, 54, 174, 58
local PAGES = {
lower = { "qwertyuiop", "asdfghjkl", "zxcvbnm" },
upper = { "QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM" },
numbers = { "1234567890", "-/:;()$&@", ".,?!'" },
symbols = { "[]{}#%^*+=", "_\\|~<>$&@", ".,?!'" },
}
-- Keyed by node id, because the node itself is sixteen bytes in the firmware and holds
-- nothing a keyboard cares about.
local state = {}
local function chars(page, row)
return PAGES[page][row]
end
local function rowWidth(count)
return count * KEY_W + (count - 1) * KEY_GAP
end
local function centeredX(rect, width)
return rect.x + (rect.w - width) // 2
end
local function thirdRow(page, rect)
local value = chars(page, 3)
local width = SIDE_W * 2 + KEY_GAP * 2 + rowWidth(#value)
return value, centeredX(rect, width)
end
-- Pure geometry: which key covers a point, given a page and the rectangle the board was
-- placed in. Kept free of the node tree so the host tests can exercise the maths that
-- actually decides what a tap enters.
function M.keyAt(page, rect, x, y)
local localY = y - rect.y
if localY < 0 then
return
end
local row = localY // (KEY_H + ROW_GAP) + 1
if row > 4 or localY % (KEY_H + ROW_GAP) >= KEY_H then
return
end
if row <= 2 then
local value = chars(page, row)
local localX = x - centeredX(rect, rowWidth(#value))
if localX < 0 then
return
end
local column = localX // (KEY_W + KEY_GAP) + 1
if column <= #value and localX % (KEY_W + KEY_GAP) < KEY_W then
return (row - 1) * 10 + column, "char", value:sub(column, column)
end
return
end
if row == 3 then
local value, start = thirdRow(page, rect)
local localX = x - start
if localX < 0 then
return
end
if localX < SIDE_W then
return 90, (page == "lower" or page == "upper") and "shift" or "symbols"
end
localX = localX - SIDE_W - KEY_GAP
local width = rowWidth(#value)
if localX >= 0 and localX < width then
local column = localX // (KEY_W + KEY_GAP) + 1
if localX % (KEY_W + KEY_GAP) < KEY_W then
return 20 + column, "char", value:sub(column, column)
end
return
end
localX = localX - width - KEY_GAP
if localX >= 0 and localX < SIDE_W then
return 91, "backspace"
end
return
end
local width = MODE_W + SPACE_W + OK_W + KEY_GAP * 2
local localX = x - centeredX(rect, width)
if localX < 0 then
return
end
if localX < MODE_W then
return 100, "mode"
end
localX = localX - MODE_W - KEY_GAP
if localX >= 0 and localX < SPACE_W then
return 101, "space"
end
localX = localX - SPACE_W - KEY_GAP
if localX >= 0 and localX < OK_W then
return 102, "submit"
end
end
local function drawArrow(x, y, width, color, down)
local middle = x + width // 2
if down then
gui.drawLine(middle, y + 8, middle, y + 20, color)
gui.drawLine(middle - 5, y + 15, middle, y + 20, color)
gui.drawLine(middle, y + 20, middle + 5, y + 15, color)
else
gui.drawLine(middle, y + 9, middle, y + 21, color)
gui.drawLine(middle - 5, y + 14, middle, y + 9, color)
gui.drawLine(middle, y + 9, middle + 5, y + 14, color)
end
end
local function drawKey(id, label, x, y, width, pressed)
local theme = ui.theme
local face = pressed and theme.pressedFace or theme.face
local color = pressed and theme.pressedColor or theme.color
gui.roundRect(x, y, width, KEY_H, theme.radius, theme.background, face, nil, color)
if label == "shift" then
drawArrow(x, y, width, color, state[id].page == "upper")
else
gui.drawText(
FONT,
x + (width - gui.getTextWidth(FONT, label)) // 2,
y + (KEY_H - gui.getFontHeight(FONT)) // 2,
label,
color,
nil,
face
)
end
end
-- Walks every key, handing each one to `visit`. Painting the whole board and repainting a
-- single key are the same traversal, so a key's position is defined in one place. Pure,
-- for the same reason keyAt is.
function M.eachKey(page, rect, visit)
local y = rect.y
for row = 1, 2 do
local value = chars(page, row)
local x = centeredX(rect, rowWidth(#value))
for column = 1, #value do
visit((row - 1) * 10 + column, value:sub(column, column), x, y, KEY_W)
x = x + KEY_W + KEY_GAP
end
y = y + KEY_H + ROW_GAP
end
local value, x = thirdRow(page, rect)
visit(90, (page == "lower" or page == "upper") and "shift" or (page == "numbers" and "#+=" or "123"), x, y, SIDE_W)
x = x + SIDE_W + KEY_GAP
for column = 1, #value do
visit(20 + column, value:sub(column, column), x, y, KEY_W)
x = x + KEY_W + KEY_GAP
end
visit(91, "<-", x, y, SIDE_W)
y = y + KEY_H + ROW_GAP
local width = MODE_W + SPACE_W + OK_W + KEY_GAP * 2
x = centeredX(rect, width)
visit(100, (page == "lower" or page == "upper") and "123" or "ABC", x, y, MODE_W)
x = x + MODE_W + KEY_GAP
visit(101, "space", x, y, SPACE_W)
x = x + SPACE_W + KEY_GAP
visit(102, "OK", x, y, OK_W)
end
local function rectOf(id)
local x, y, w, h = node.getRect(id)
return { x = x, y = y, w = w, h = h }
end
local function paint(id)
M.eachKey(state[id].page, rectOf(id), function(key, label, x, y, width)
drawKey(id, label, x, y, width, false)
end)
end
-- Repaints one key in place. The keyboard is a single node, so there is no parent to
-- clear and nothing else on screen can have moved.
local function drawOneKey(id, target, pressed)
M.eachKey(state[id].page, rectOf(id), function(key, label, x, y, width)
if key == target then
drawKey(id, label, x, y, width, pressed)
end
end)
end
local function down(id, x, y)
local key = M.keyAt(state[id].page, rectOf(id), x, y)
state[id].activeKey = key
if key then
drawOneKey(id, key, true)
end
end
-- The release repaints the key but keeps activeKey, because ui fires on_exit before
-- on_click and the click still has to know which key the press started on.
local function unpress(id)
local key = state[id].activeKey
if key then
drawOneKey(id, key, false)
end
end
local function press(id, x, y)
local st = state[id]
local key, action, char = M.keyAt(st.page, rectOf(id), x, y)
if not key or key ~= st.activeKey then
return
end
st.activeKey = nil
local function changed()
if st.on_change then
st.on_change(st.value)
end
end
if action == "char" then
if #st.value < st.max_length then
st.value = st.value .. char
changed()
end
elseif action == "shift" then
st.page = st.page == "lower" and "upper" or "lower"
node.invalidate(id)
elseif action == "symbols" then
st.page = st.page == "numbers" and "symbols" or "numbers"
node.invalidate(id)
elseif action == "mode" then
st.page = (st.page == "lower" or st.page == "upper") and "numbers" or "lower"
node.invalidate(id)
elseif action == "backspace" then
st.value = st.value:sub(1, -2)
changed()
elseif action == "space" then
if #st.value < st.max_length then
st.value = st.value .. " "
changed()
end
elseif action == "submit" and st.on_submit then
st.on_submit(st.value)
end
end
-- One custom-painted node keeps a full keyboard off the heap: ordinary ui.button keys
-- would add dozens of nodes and their styles while wifi is already holding buffers.
function M.new(spec)
spec = spec or {}
local id = ui.custom {
h = 4 * KEY_H + 3 * ROW_GAP,
paint = paint,
on_enter = down,
on_click = press,
on_exit = unpress,
press_style = false, -- a key highlights itself; the node never does
}
state[id] = {
value = spec.value or "",
max_length = spec.max_length or 64,
page = "lower",
on_change = spec.on_change,
on_submit = spec.on_submit,
}
return id
end
return M
+88
View File
@@ -0,0 +1,88 @@
-- Navigation, which the firmware has no idea about: sys.startApp tears the runtime down
-- and starts over from a file, so where the app came from has to survive in the arguments
-- rather than in C++. This module is the one writer of that table.
local MAIN = "/.lua/main.lua"
local M = {}
local route, arg, history, title = "Home", nil, {}, "Home"
---@return string
local function appId()
return route:match "^[^/]+" or route
end
---Adopts the arguments the firmware handed start(); main.lua calls this first.
---@param args table|nil
function M.start(args)
args = args or {}
route = args.app or "Home"
arg = args.arg
history = args.history or {}
title = args.title or appId()
end
---@return string The route the running app was launched with.
function M.getRoute()
return route
end
---@return string|nil The string the launching app passed alongside the route.
function M.getArg()
return arg
end
---@return string The bar's label, initially the app's first path component.
function M.getTitle()
return title
end
---Retitles the bar for a screen within an app. The bar rebuilds rather than repaints,
---because a longer title needs a wider box.
---@param text string
function M.setTitle(text)
title = text
end
---@return string The app's persistent directory, one per app rather than per route.
function M.getDataPath()
return "/.lua/data/" .. appId()
end
---@return boolean Whether back() would return somewhere rather than land on the launcher.
function M.canGoBack()
return #history > 0
end
local function restart(target, stack)
sys.startApp(MAIN, { app = target.app, arg = target.arg, history = stack })
end
local function pushed()
local stack = table.move(history, 1, #history, 1, {})
stack[#stack + 1] = { app = route, arg = arg }
return stack
end
---Launches a route and remembers the current one.
---@param target string
---@param value string|nil
function M.launch(target, value)
restart({ app = target, arg = value }, pushed())
end
---Launches a route without retaining the current one, so back skips past it.
---@param target string
---@param value string|nil
function M.replace(target, value)
restart({ app = target, arg = value }, history)
end
---Returns to the previous route, or to the launcher once history is empty.
function M.back()
local stack = table.move(history, 1, #history, 1, {})
restart(table.remove(stack) or { app = "Home" }, stack)
end
return M
-169
View File
@@ -1,169 +0,0 @@
-- The top bar, painted by the firmware once a second in every app. It draws in panel
-- coordinates (the firmware drops the app viewport around the call), so gui.getWidth()
-- here is the whole screen.
--
-- Invalidation is entirely local: draw() compares what it is about to paint against what
-- it last painted, so the firmware never has to tell the bar that anything changed.
local ui = require "ui"
local FONT = gui.FONT_SMALL
-- The app name reads as a heading, matching a card title; the clock and memory
-- stay small so the right-hand stack still fits two rows.
local TITLE_FONT = gui.FONT_UI
local PAD = 6
-- The firmware reads both: `height` is the strip it keeps apps out of, `interval` is how
-- often it calls draw().
local M = { height = 44, interval = 1000 }
local BAR_H = M.height
local WIFI_W = 11
local WIFI_HEIGHTS = { 3, 5, 8 }
local GAP = 8
local ROW_INSET = 8
-- What is currently on the panel. Every field repaints only when its value changes, so
-- the once-a-second tick costs one clock rectangle instead of a whole bar.
local shown = {}
local geometry = {}
local function clock()
return sys.isClockSynced() and os.date "%H:%M:%S" or "--:--:--"
end
local function signalBars()
local status = wifi.getStatus()
if status.state ~= "connected" then
return 0, true
end
return status.rssi >= -60 and 3 or status.rssi >= -75 and 2 or 1, false
end
local function memory()
local free, total = sys.getMemory()
local used = math.max(0, (total or 0) - (free or 0))
return (used + 512) // 1024 .. "kB"
end
local function drawWifi(x, y, bars, offline, theme)
local empty = offline and theme.disabled or theme.muted
for i, h in ipairs(WIFI_HEIGHTS) do
gui.fillRect(x + (i - 1) * 4, y + 8 - h, 3, h, i <= bars and theme.color or empty)
end
if offline then
gui.drawLine(x, y, x + WIFI_W - 1, y + 8, theme.muted)
end
end
-- Fills the leading square of the bar, which is the rect the firmware treats as back.
-- Drawn as a button rather than left as a hidden hit region: a target nobody can see is
-- one nobody finds.
local function drawBack(theme, pressed)
local inset = 3
local side = BAR_H - 1 - inset * 2
local face = pressed and theme.pressedFace or theme.face
local color = pressed and theme.pressedColor or theme.color
-- Borderless: the fill alone carries the pressed state, and an outline here
-- competes with the chevron at this size.
gui.roundRect(inset, inset, side, side, 4, theme.background, face)
local x, y = inset + side // 2 + 1, inset + side // 2
for offset = 0, 1 do -- two passes, because a one pixel chevron reads as a speck
gui.drawLine(x + offset, y - 4, x + offset - 4, y, color)
gui.drawLine(x + offset - 4, y, x + offset, y + 4, color)
end
end
-- Leaving fullscreen is the one change the bar cannot observe: no draw happens while an
-- app owns the panel, so the state it last painted still matches the state it would paint
-- now, while the pixels are gone. Apps toggle fullscreen through here for that reason.
function M.setFullscreen(on)
gui.setFullscreen(on)
if not on then
shown = {}
end
end
function M.draw(hasBack, backPressed)
local theme = ui.theme
local frameKey = gui.getRotation()
if geometry.key ~= frameKey then
local w = gui.getWidth()
local fontH = gui.getFontHeight(FONT)
local clockW = gui.getTextWidth(FONT, "00:00:00")
local memW = gui.getTextWidth(FONT, "000kB")
local clockX = w - PAD - clockW
local signalX = w - PAD - WIFI_W
local memX = signalX - GAP - memW
geometry = {
key = frameKey,
w = w,
titleY = (BAR_H - 1 - gui.getFontHeight(TITLE_FONT)) // 2,
topY = ROW_INSET,
bottomY = BAR_H - 1 - ROW_INSET - fontH,
fontH = fontH,
clockW = clockW,
memW = memW,
clockX = clockX,
signalX = signalX,
memX = memX,
rightX = math.min(clockX, memX),
}
end
local w, titleY, topY, bottomY = geometry.w, geometry.titleY, geometry.topY, geometry.bottomY
local fontH, clockW, memW = geometry.fontH, geometry.clockW, geometry.memW
local clockX, signalX, memX, rightX = geometry.clockX, geometry.signalX, geometry.memX, geometry.rightX
-- Rotation moves every slot, the theme recolors them, and an app may rename itself at
-- any time. A new app gets a fresh Lua state, so an empty cache already means "repaint
-- everything".
local name = sys.getAppTitle()
local key = frameKey .. "|" .. ui.getTheme() .. "|" .. name
if key ~= shown.key then
shown = { key = key }
gui.fillRect(0, 0, w, BAR_H, theme.background)
gui.fillRect(0, BAR_H - 1, w, 1, theme.muted) -- a rule, so the bar reads as chrome
if hasBack then
drawBack(theme, backPressed)
end
shown.backPressed = backPressed
local titleLeft = hasBack and BAR_H + PAD or PAD
local titleRight = rightX - GAP
local available = titleRight - titleLeft
while #name > 1 and gui.getTextWidth(TITLE_FONT, name) > available do
name = name:sub(1, -2)
end
local nameW = gui.getTextWidth(TITLE_FONT, name)
local nameX = (w - nameW) // 2
nameX = math.max(titleLeft, math.min(nameX, titleRight - nameW))
gui.drawText(TITLE_FONT, nameX, titleY, name, theme.color, nil, theme.background)
end
local mem = memory()
if mem ~= shown.mem then
shown.mem = mem
gui.fillRect(memX, bottomY, memW, fontH, theme.background)
gui.drawText(FONT, memX, bottomY, mem, theme.muted, nil, theme.background)
end
if hasBack and backPressed ~= shown.backPressed then
shown.backPressed = backPressed
drawBack(theme, backPressed)
end
local bars, offline = signalBars()
if bars ~= shown.bars or offline ~= shown.offline then
shown.bars, shown.offline = bars, offline
gui.fillRect(signalX, bottomY, WIFI_W, math.max(fontH + 1, 11), theme.background)
drawWifi(signalX, bottomY, bars, offline, theme)
end
local time = clock()
if time ~= shown.time then
shown.time = time
gui.fillRect(clockX, topY, clockW, fontH, theme.background)
gui.drawText(FONT, clockX, topY, time, theme.muted, nil, theme.background)
end
end
return M
+61
View File
@@ -0,0 +1,61 @@
-- 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.
--
-- Scrolling, its flick and the drag-vs-tap arbitration are the tree's now (ui.box{scrollY}),
-- so this file is only rows plus the single-select semantics the settings screens share.
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)
message = ok and nil or (err or "save failed")
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
+186
View File
@@ -0,0 +1,186 @@
-- The top strip, built into the same tree as the running app by /.lua/main.lua.
--
-- It keeps no cache of what it painted: each field is its own node, so ui.setText()
-- repaints a clock that ticked and leaves the rest of the bar alone. The title is the
-- exception -- a longer one needs a wider box -- so a change to it rebuilds the screen.
local ui = require "ui"
local nav = require "nav"
local FONT = screen.FONT_SMALL
-- The app name reads as a heading, matching a card title; the clock and memory
-- stay small so the right-hand stack still fits two rows.
local TITLE_FONT = screen.FONT_UI
local BAR_H = 44
local PAD, GAP = 6, 8
local WIFI_W, WIFI_HEIGHTS = 11, { 3, 5, 8 }
local M = { height = BAR_H }
local ids = {}
local title, shownBars, shownOffline
local hidden, built = false, false
local function clock()
return sys.isClockSynced() and os.date "%H:%M:%S" or "--:--:--"
end
local function signalBars()
local status = wifi.getStatus()
if status.state ~= "connected" then
return 0, true
end
return status.rssi >= -60 and 3 or status.rssi >= -75 and 2 or 1, false
end
local function memory()
local free, total = sys.getMemory()
local used = math.max(0, (total or 0) - (free or 0))
return (used + 512) // 1024 .. "kB"
end
local function paintWifi(_, x, y)
local theme = ui.theme
local bars, offline = signalBars()
local empty = offline and theme.disabled or theme.muted
for i, h in ipairs(WIFI_HEIGHTS) do
screen.fillRect(x + (i - 1) * 4, y + 8 - h, 3, h, i <= bars and theme.color or empty)
end
if offline then
screen.drawLine(x, y, x + WIFI_W - 1, y + 8, theme.muted)
end
end
-- Painted rather than assembled from a button and a glyph: the chevron is four lines at
-- this size, and a custom node keeps the press feedback to one repaint of one box.
local function paintBack(id, x, y, w, h)
local theme = ui.theme
local pressed = tree.isPressed(id)
local face = pressed and theme.pressedFace or theme.face
local color = pressed and theme.pressedColor or theme.color
screen.roundRect(x, y, w, h, 4, theme.background, face)
local cx, cy = x + w // 2 + 1, y + h // 2
for offset = 0, 1 do -- two passes, because a one pixel chevron reads as a speck
screen.drawLine(cx + offset, cy - 4, cx + offset - 4, cy, color)
screen.drawLine(cx + offset - 4, cy, cx + offset, cy + 4, color)
end
end
local function backNode()
local side = BAR_H - 6
return ui.custom {
w = side,
h = side,
paint = paintBack,
press_style = false, -- paintBack draws its own, so nothing else should repaint
on_enter = function(id)
tree.setPressed(id, true)
ui.invalidate(id)
end,
on_exit = function(id)
tree.setPressed(id, false)
ui.invalidate(id)
end,
on_click = function()
nav.back()
end,
}
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")
local memW = screen.getTextWidth(FONT, "0000kB")
local fontH = screen.getFontHeight(FONT)
ids = {}
ids.clock = ui.text(clock(), { w = clockW, h = fontH, font = FONT, color = theme.muted })
ids.memory = ui.text(memory(), { w = memW, h = fontH, font = FONT, color = theme.muted })
ids.wifi = ui.custom { w = WIFI_W, h = 9, paint = paintWifi }
shownBars, shownOffline = signalBars() -- what the first paint is about to show
-- Every slot is sized here rather than left to fill: a fill in a row takes the whole
-- width, and the three slots have to share it.
local statusW = math.max(clockW, memW + GAP + WIFI_W)
local backW = nav.canGoBack() and BAR_H - 6 or 0
local titleW = screen.getWidth() - 2 * PAD - 2 * GAP - backW - statusW
local status = ui.box {
w = statusW,
gap = 4,
align = "end",
ids.clock,
ui.box { row = true, w = memW + GAP + WIFI_W, gap = GAP, align = "center", ids.memory, ids.wifi },
}
-- A rule under the strip, so the bar reads as chrome rather than as the top of the app.
return ui.box {
w = "fill",
h = BAR_H,
background = theme.background,
ui.box {
w = "fill",
h = BAR_H - 1,
row = true,
pad = PAD,
gap = GAP,
align = "center",
backW > 0 and backNode() or ui.spacer { w = 0, h = 1 },
ui.box {
row = true,
w = titleW,
justify = "center",
align = "center",
ui.label(title, { font = TITLE_FONT, fit = titleW }),
},
status,
},
ui.box { w = "fill", h = 1, background = theme.muted },
}
end
---Repaints only the fields that changed. A new title needs a new box, so that one is a
---rebuild of the screen rather than a repaint of the bar.
function M.tick()
if hidden then
return
end
if nav.getTitle() ~= title then
ui.rebuild()
return
end
ui.setText(ids.clock, clock())
ui.setText(ids.memory, memory())
local bars, offline = signalBars()
if bars ~= shownBars or offline ~= shownOffline then
shownBars, shownOffline = bars, offline
ui.invalidate(ids.wifi)
end
end
---@return boolean
function M.isVisible()
return not hidden
end
---Hands the whole panel to the app, or takes the strip back.
---@param on boolean
function M.setFullscreen(on)
if hidden == on then
return
end
hidden = on
-- 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
+75
View File
@@ -0,0 +1,75 @@
package.path = "/.lua/lib/?.lua"
local ui = require "ui"
local nav = require "nav"
local statusbar = require "ui.statusbar"
local APPS = "/.lua/apps/"
---@class Main : App, TouchHandlers
local M = {}
---@class SlateApp
---@field init? fun(arg?: string) Builds state before the tree is mounted.
---@field node? fun(): NodeId The app's subtree, built inside the chrome.
---@field draw? fun(deltaMs: integer)
local app
---@param args table|nil What the previous state passed to sys.startApp, or nil at boot.
function M.start(args)
nav.start(args)
local dir = APPS .. nav.getRoute()
package.path = dir .. "/?.lua;/.lua/lib/?.lua"
app = assert(loadfile(dir .. "/main.lua"))()
if app.init then
app.init(nav.getArg())
end
ui.mount(M.node)
timer.every(1000, statusbar.tick)
end
---@return NodeId
function M.node()
ui.setInset(statusbar.isVisible() and statusbar.height or 0)
local body = ui.box { w = "fill", h = "fill", app.node and app.node() or nil }
if not statusbar.isVisible() then
return body
end
return ui.box { w = "fill", h = "fill", statusbar.node(), body }
end
function M.draw(deltaMs)
if app.draw then
app.draw(deltaMs)
end
ui.draw(deltaMs)
end
function M.onTouchDown(x, y)
ui.down(x, y)
if app.onTouchDown then
app.onTouchDown(x, y)
end
end
function M.onTouchMove(x, y)
ui.move(x, y)
if app.onTouchMove then
app.onTouchMove(x, y)
end
end
function M.onTouchUp(x, y)
ui.up(x, y)
if app.onTouchUp then
app.onTouchUp(x, y)
end
end
function M.onTouch(x, y)
if app.onTouch then
app.onTouch(x, y)
end
end
return M
+7
View File
@@ -0,0 +1,7 @@
return {
rotation = 0,
timezone = "UTC",
theme = "light",
touch = { x0 = 240, y0 = 3800, x1 = 3800, y1 = 200 },
wifi = { ssid = "qemu", password = "" },
}
+16 -149
View File
@@ -1,43 +1,11 @@
#include "lua_host.h"
#include <algorithm>
#include "../net.h"
#include "../settings.h"
extern "C" {
#include <lauxlib.h>
#include <lua.h>
}
namespace {
esp32lua::Paths slatePaths() {
esp32lua::Paths paths;
paths.apps = "/.lua/apps";
paths.data = "/.lua/data";
paths.lib = "/.lua/lib";
paths.home = "Home";
return paths;
}
// Reads an optional positive integer field off the bar module on the stack top.
lua_Integer barField(lua_State* state, const char* key, lua_Integer fallback) {
lua_getfield(state, -1, key);
const lua_Integer value =
lua_isinteger(state, -1) ? lua_tointeger(state, -1) : fallback;
lua_pop(state, 1);
return value > 0 ? value : fallback;
}
} // namespace
// Called from the initializer list once every provider member exists, which
// declaration order guarantees: the runtime holds pointers to them for its
// whole life.
esp32lua::Providers LuaHost::wire() {
esp32lua::Providers providers;
providers.log = &logProvider;
providers.settings = &settingsProvider;
providers.sys = &sysProvider;
providers.fs = &fsProvider;
providers.gui = &guiProvider;
@@ -46,15 +14,12 @@ esp32lua::Providers LuaHost::wire() {
providers.wifi = &wifiProvider;
providers.ble = &bleProvider;
providers.touch = &touchProvider;
// No buttons on this board, so sys.hasFeature("buttons") is false and input
// gains nothing.
return providers;
}
LuaHost::LuaHost(TFT_eSPI& tft, XPT2046_Touchscreen& touch)
: tft(tft), touchPanel(touch), settingsProvider(*this),
guiProvider(tft, *this), touchProvider(touch, *this),
runtime(wire(), slatePaths()) {}
: tft(tft), touchPanel(touch), guiProvider(tft, *this),
touchProvider(touch, *this), runtime(wire()) {}
void LuaHost::mapTouch(const TS_Point& point, int16_t& x, int16_t& y) const {
const int16_t nx =
@@ -81,119 +46,42 @@ void LuaHost::mapTouch(const TS_Point& point, int16_t& x, int16_t& y) const {
y = ny;
break;
}
// App space starts below the bar, matching the viewport apps draw into. A tap
// on the bar itself lands at a negative y, which pollTouch() drops.
y -= barInset();
}
// resetViewport() first: width()/height() report the viewport once one is set,
// so re-applying over an existing one would shrink the app area again every
// time. setRotation() leaves the old viewport metrics behind, so every rotation
// needs this too.
void LuaHost::applyViewport() {
const int16_t inset = barInset();
tft.resetViewport();
if (inset > 0)
tft.setViewport(0, inset, tft.width(), tft.height() - inset, true);
}
void LuaHost::applyRotation() {
tft.setRotation(settings.rotationIndex());
applyViewport();
refreshStatusBar(); // every slot in the bar just moved
}
void LuaHost::setFullscreen(bool on) {
fullscreen = on;
applyViewport();
nextBarMs = 0; // leaving fullscreen left the bar's rows painted by the app
}
// The tree Lua built was placed against the old frame, so main.lua rebuilds it.
// Nothing here can do that: the panel is the only part of a rotation the
// firmware owns.
void LuaHost::applyRotation() { tft.setRotation(settings.rotationIndex()); }
bool LuaHost::begin() {
prepareForApp();
if (!runtime.startApp("Home"))
// No arguments, which is how main.lua knows this is a boot and picks its own
// launcher; everything after this is a route Lua put in the arguments.
if (!runtime.startApp(esp32lua::MAIN_PATH))
return false;
settleNewApp();
return true;
}
void LuaHost::prepareForApp() {
tft.setRotation(
settings.rotationIndex()); // the previous app may have rotated the frame
fullscreen = false;
// Applied before the app loads, because init() measures the panel it was
// given. The height is the last app's, which is the same module, and
// settleNewApp() corrects it if that changes.
applyViewport();
tft.setRotation(settings.rotationIndex());
lastTouched = true; // the tap that launched this app may still be down
ignoreRelease = true; // and its release is not this app's gesture
backArmed = false;
Serial.printf("[lua] launching free=%u largest=%u\n", ESP.getFreeHeap(),
ESP.getMaxAllocHeap());
}
void LuaHost::settleNewApp() {
hasBack = runtime.canGoBack(); // the runtime keeps the history; the bar only
// offers the control
loadStatusBar();
applyViewport();
if (barInset() > 0 && !barBroken)
drawStatusBar();
const uint32_t now = millis();
nextDrawMs = now;
lastDrawMs = now;
Serial.printf("[lua] running %s\n", runtime.appPath().c_str());
}
// The bar is a Lua module like any other, loaded per app because the state is
// too.
void LuaHost::loadStatusBar() {
lua_State* state = runtime.state();
barBroken = false;
barIntervalMs = BAR_INTERVAL_MS;
lua_getglobal(state, "require");
lua_pushstring(state, "statusbar");
if (lua_pcall(state, 1, 1, 0) != LUA_OK || !lua_istable(state, -1)) {
Serial.printf("[statusbar] unavailable: %s\n",
luaL_tolstring(state, -1, nullptr));
// Nothing has measured the panel yet, so surrendering the strip here is
// free.
barBroken = true;
barHeight = 0;
lua_pop(state, 2);
return;
}
barHeight = barField(state, "height", DEFAULT_BAR_H);
barIntervalMs = std::max<lua_Integer>(
DRAW_INTERVAL_MS, barField(state, "interval", BAR_INTERVAL_MS));
lua_setglobal(state, "__statusbar");
nextBarMs = 0;
}
// Errors here disable the bar rather than killing the app: chrome that fails
// should not take the running program with it.
void LuaHost::drawStatusBar() {
lua_State* state = runtime.state();
lua_getglobal(state, "__statusbar");
lua_getfield(state, -1, "draw");
lua_remove(state, -2);
lua_pushboolean(state, hasBack);
lua_pushboolean(state, backArmed);
tft.resetViewport(); // the bar paints in panel coordinates, the app does not
if (lua_pcall(state, 2, 0, 0) != LUA_OK) {
Serial.printf("[statusbar] %s\n", luaL_tolstring(state, -1, nullptr));
lua_pop(state, 2);
barBroken = true;
}
applyViewport();
}
// Deliberately not themed: this can fire when the settings naming the theme are
// themselves unreadable, so it stays high contrast.
void LuaHost::fail(const char* message) {
Serial.printf("[lua] %s\n", message ? message : "(unknown)");
tft.resetViewport();
tft.fillScreen(TFT_WHITE);
tft.setTextSize(2);
tft.setTextColor(TFT_RED, TFT_WHITE);
@@ -203,28 +91,10 @@ void LuaHost::fail(const char* message) {
}
void LuaHost::pollTouch() {
bool touched = touchPanel.touched();
const bool touched = touchPanel.touched();
if (touched)
mapTouch(touchPanel.getPoint(), lastX, lastY);
// The bar is the host's: apps never see a touch in it, and its one control
// acts on release, so a press that slides off into the app cancels like any
// other button.
if (touched && lastY < 0) {
if (!backArmed && inBackButton()) {
backArmed = true;
drawStatusBar();
}
touched = false;
} else if (backArmed) {
backArmed = false;
if (inBackButton()) {
runtime.requestBack();
return;
}
drawStatusBar();
}
if (touched && !lastTouched) {
movedX = lastX;
movedY = lastY;
@@ -241,7 +111,7 @@ void LuaHost::pollTouch() {
} else if (!touched && lastTouched && ignoreRelease) {
ignoreRelease = false;
} else if (!touched && lastTouched) {
// The runtime fires the on_touch tap alias after the release.
// The runtime fires the onTouch tap alias after the release.
runtime.callTouch(esp32lua::TouchPhase::Up, lastX, lastY);
}
lastTouched = touched;
@@ -258,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");
@@ -281,12 +154,6 @@ void LuaHost::loop() {
lastDrawMs = now;
}
if (!runtime.hasPendingNavigation() && barInset() > 0 && !barBroken &&
now >= nextBarMs) {
nextBarMs = now + barIntervalMs;
drawStatusBar();
}
// Between batches, never inside one: swapping the lua_State mid-callback
// would free the VM that is still executing.
if (runtime.hasPendingNavigation())
+5 -29
View File
@@ -1,8 +1,9 @@
#pragma once
// The firmware side of running a Lua app: the panel, the touch controller, the
// status bar and the frame pacing. The lua_State, the bindings, the node tree,
// app loading and navigation history all belong to esp32lua::Runtime.
// The firmware side of running a Lua app: the panel, the touch controller and
// the frame pacing. The lua_State, the bindings, the node tree, app loading and
// navigation history all belong to esp32lua::Runtime, and everything the user
// sees -- chrome included -- is built by /.lua/main.lua.
#include <TFT_eSPI.h>
#include <XPT2046_Touchscreen.h>
@@ -20,45 +21,29 @@ public:
bool running() const { return runtime.hasApp(); }
// Called by the providers, which is why they hold a reference to the host.
void applyViewport();
void applyRotation();
void setFullscreen(bool on);
void refreshStatusBar() { nextBarMs = 0; }
void mapTouch(const TS_Point& point, int16_t& x, int16_t& y) const;
private:
static constexpr uint32_t DRAW_INTERVAL_MS = 33;
static constexpr uint32_t BAR_INTERVAL_MS = 1000;
static constexpr int16_t DEFAULT_BAR_H = 22;
static constexpr int16_t PANEL_W = 320;
static constexpr int16_t PANEL_H = 480;
static constexpr int16_t MOVE_EPSILON_PX = 2;
static constexpr uint32_t ERROR_HOLD_MS = 5000;
int16_t barInset() const { return fullscreen ? 0 : barHeight; }
bool inBackButton() const {
return hasBack && lastY < 0 && lastY >= -barInset() && lastX >= 0 &&
lastX < barInset();
}
esp32lua::Providers wire();
// Every app starts the same way, whether it is the launcher at boot or a
// route the running app asked for: prepare the panel, load, then re-clip
// around the new bar.
// route the running app asked for.
void prepareForApp();
void settleNewApp();
void navigate();
void pollTouch();
void pollTimers();
void loadStatusBar();
void drawStatusBar();
void fail(const char* message);
TFT_eSPI& tft;
XPT2046_Touchscreen& touchPanel;
slate::Log logProvider;
slate::Settings settingsProvider;
slate::Sys sysProvider;
slate::Fs fsProvider;
slate::Gui guiProvider;
@@ -71,15 +56,6 @@ private:
uint32_t nextDrawMs = 0;
uint32_t lastDrawMs = 0;
uint32_t nextBarMs = 0;
uint32_t barIntervalMs = BAR_INTERVAL_MS;
// Kept across apps: the bar is the same module for every one of them, and the
// viewport has to be right before init() measures the panel it was given.
int16_t barHeight = DEFAULT_BAR_H;
bool barBroken = false;
bool fullscreen = false;
bool hasBack = false;
bool backArmed = false;
bool lastTouched = false;
bool ignoreRelease = false;
int16_t lastX = 0, lastY = 0;
+24 -14
View File
@@ -9,6 +9,7 @@
#include <XPT2046_Touchscreen.h>
#include <lua/providers.h>
#include <cstdint>
#include <map>
class LuaHost;
@@ -21,23 +22,13 @@ public:
void write(esp32lua::LogLevel level, const std::string& message) override;
};
class Settings : public esp32lua::SettingsProvider {
public:
explicit Settings(LuaHost& host) : host(host) {}
int32_t rotation() const override;
esp32lua::Status setRotation(int32_t degrees) override;
std::string timezone() const override;
esp32lua::Status setTimezone(const std::string& timezone) override;
private:
LuaHost& host;
};
class Sys : public esp32lua::SysProvider {
public:
int32_t millis() const override;
esp32lua::MemoryInfo memory() const override;
bool isClockSynced() const override;
std::string timezone() const override;
esp32lua::Status setTimezone(const std::string& timezone) override;
};
class Fs : public esp32lua::FsProvider {
@@ -75,7 +66,9 @@ public:
int32_t width() const override;
int32_t height() const override;
int32_t rotation() const override;
void setRotation(int32_t degrees) override;
esp32lua::Status setRotation(int32_t degrees) override;
std::string theme() const override;
esp32lua::Status setTheme(const std::string& theme) override;
int32_t color(int32_t r, int32_t g, int32_t b) const override;
void clear(int32_t color) override;
void fillRect(int32_t x, int32_t y, int32_t w, int32_t h,
@@ -103,13 +96,30 @@ public:
void drawText(int32_t font, int32_t x, int32_t y, const std::string& text,
int32_t color, int32_t style,
const int32_t* background) override;
void setFullscreen(bool on) override;
// The panel is live, so there is nothing pending to apply.
void commit() override {}
bool beginBuffer(int32_t x, int32_t y, int32_t w, int32_t h) override;
void present() override;
void setClip(int32_t x, int32_t y, int32_t w, int32_t h) override;
void clearClip() override;
private:
// Every draw op targets this: the panel normally, an 8bpp sprite between
// beginBuffer and present. A full-panel 16bpp sprite (300KB) will not
// allocate on this board's fragmented heap, so the band trades RGB332 banding
// for a size that fits, and the painter composites the frame a band at a time.
TFT_eSPI& out() { return buffer ? *buffer : tft; }
TFT_eSPI& tft;
LuaHost& host;
TFT_eSprite* buffer = nullptr;
// The band's screen origin, subtracted from every coordinate so callers draw
// in screen space; zero (a no-op) while drawing straight to the panel.
int32_t bufX = 0, bufY = 0;
// Sprite bounds, so roundRect's corner spans clip to the band. TFT_eSprite
// clips fills and text but not pushImage, and an unclipped span past the
// sprite corrupts the heap. INT32_MAX on the panel disables the clamp.
int32_t clipW = INT32_MAX, clipH = INT32_MAX;
};
class Http : public esp32lua::HttpProvider {
+130 -33
View File
@@ -2,6 +2,7 @@
#include <SD.h>
#include "../gfx/round_rect.h"
#include "../settings.h"
#include "lua_host.h"
#include "providers.h"
@@ -17,9 +18,35 @@ constexpr int MAX_SPAN =
// alpha, so edge pixels blend against `surface`.
void paintRoundRect(TFT_eSPI& tft, int x, int y, int w, int h, float radius,
uint16_t surface, bool hasFill, uint16_t top,
uint16_t bottom, bool hasBorder, uint16_t border) {
uint16_t bottom, bool hasBorder, uint16_t border,
int clipW, int clipH) {
if (w <= 0 || h <= 0 || w > MAX_SPAN)
return;
// TFT_eSprite clips fills and text but not pushImage, so a corner span past a
// band's edge corrupts the heap. Clamp each span to the target bounds; the
// panel passes INT32_MAX bounds, so this is a no-op there.
// Per-pixel rather than pushImage: pushImage into a TFT_eSprite corrupts the
// heap when the span sits near a band edge, whereas drawPixel clips cleanly on
// both a sprite and the panel. Spans are corner-sized, so the cost is trivial.
auto pushSpan = [&](int px, int py, int pw, uint16_t* pixels) {
if (py < 0 || py >= clipH)
return;
for (int i = 0; i < pw; i++) {
const int xx = px + i;
if (xx >= 0 && xx < clipW)
tft.drawPixel(xx, py, pixels[i]);
}
};
// TFT_eSprite does not reliably clip a fill or line taller than the band, so
// a straddling rounded box would run off the buffer. Clamp every write here.
auto fillClip = [&](int px, int py, int pw, int ph, uint16_t color) {
int x0 = px < 0 ? 0 : px, y0 = py < 0 ? 0 : py;
int x1 = px + pw > clipW ? clipW : px + pw;
int y1 = py + ph > clipH ? clipH : py + ph;
if (x1 > x0 && y1 > y0)
tft.fillRect(x0, y0, x1 - x0, y1 - y0, color);
};
float halfWidth = w * 0.5f, halfHeight = h * 0.5f;
if (radius < 0.0f)
radius = 0.0f;
@@ -47,7 +74,7 @@ void paintRoundRect(TFT_eSPI& tft, int x, int y, int w, int h, float radius,
pixel = gfx::blend565(pixel, border, outer - inner);
span[column] = pixel;
}
tft.pushImage(x, y + row, w, 1, span);
pushSpan(x, y + row, w, span);
}
tft.setSwapBytes(previousSwap);
return;
@@ -57,19 +84,19 @@ void paintRoundRect(TFT_eSPI& tft, int x, int y, int w, int h, float radius,
// only at corners. The cost drops from O(w*h) to O(radius^2) per-pixel work.
const uint16_t fill = hasFill ? top : surface;
if (h > 2 * ir)
tft.fillRect(x, y + ir, w, h - 2 * ir, fill);
fillClip(x, y + ir, w, h - 2 * ir, fill);
if (w > 2 * ir) {
tft.fillRect(x + ir, y, w - 2 * ir, ir, fill);
tft.fillRect(x + ir, y + h - ir, w - 2 * ir, ir, fill);
fillClip(x + ir, y, w - 2 * ir, ir, fill);
fillClip(x + ir, y + h - ir, w - 2 * ir, ir, fill);
}
if (hasBorder) {
if (w > 2 * ir) {
tft.drawFastHLine(x + ir, y, w - 2 * ir, border);
tft.drawFastHLine(x + ir, y + h - 1, w - 2 * ir, border);
fillClip(x + ir, y, w - 2 * ir, 1, border);
fillClip(x + ir, y + h - 1, w - 2 * ir, 1, border);
}
if (h > 2 * ir) {
tft.drawFastVLine(x, y + ir, h - 2 * ir, border);
tft.drawFastVLine(x + w - 1, y + ir, h - 2 * ir, border);
fillClip(x, y + ir, 1, h - 2 * ir, border);
fillClip(x + w - 1, y + ir, 1, h - 2 * ir, border);
}
}
@@ -96,7 +123,7 @@ void paintRoundRect(TFT_eSPI& tft, int x, int y, int w, int h, float radius,
pixel = gfx::blend565(pixel, border, outer - inner);
span[col] = pixel;
}
tft.pushImage(x + colStart, y + rowStart + row, ir, 1, span);
pushSpan(x + colStart, y + rowStart + row, ir, span);
}
}
tft.setSwapBytes(previousSwap);
@@ -127,33 +154,105 @@ int32_t Gui::width() const { return tft.width(); }
int32_t Gui::height() const { return tft.height(); }
int32_t Gui::rotation() const { return tft.getRotation() * 90; }
void Gui::setRotation(int32_t degrees) {
tft.setRotation((degrees / 90) & 3);
host.applyViewport();
host.refreshStatusBar(); // every slot in the bar just moved
// Applies the rotation and persists it in one call, because a setter that needs
// a follow-up is a bug in the setter: the frame, the viewport and the bar all
// move together or not at all.
esp32lua::Status Gui::setRotation(int32_t degrees) {
if (!settings.setRotation(degrees))
return esp32lua::Status::failure("rotation must be 0, 90, 180 or 270");
host.applyRotation();
return settings.save() ? esp32lua::Status::success()
: esp32lua::Status::failure("cannot save settings");
}
std::string Gui::theme() const { return settings.theme.c_str(); }
// Stores the name only. ui.setTheme() owns applying it, because the palette and
// the repaint it drives exist solely in Lua.
esp32lua::Status Gui::setTheme(const std::string& theme) {
if (theme.empty() || theme.size() > 16)
return esp32lua::Status::failure("theme must be 1 to 16 characters");
settings.theme = theme.c_str();
return settings.save() ? esp32lua::Status::success()
: esp32lua::Status::failure("cannot save settings");
}
int32_t Gui::color(int32_t r, int32_t g, int32_t b) const {
return tft.color565(r, g, b);
}
void Gui::clear(int32_t color) { tft.fillScreen(color); }
void Gui::clear(int32_t color) { out().fillScreen(color); }
void Gui::fillRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t color) {
tft.fillRect(x, y, w, h, color);
out().fillRect(x - bufX, y - bufY, w, h, color);
}
void Gui::drawRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t color) {
tft.drawRect(x, y, w, h, color);
out().drawRect(x - bufX, y - bufY, w, h, color);
}
void Gui::drawPixel(int32_t x, int32_t y, int32_t color) {
tft.drawPixel(x, y, color);
out().drawPixel(x - bufX, y - bufY, color);
}
// The painter opens one band at a time and closes it with present(). An 8bpp
// sprite of the whole panel (~140KB) will not allocate on this heap, so a band
// is a slice of it; refusing a second open keeps the first from being orphaned.
bool Gui::beginBuffer(int32_t x, int32_t y, int32_t w, int32_t h) {
if (buffer)
return false;
buffer = new TFT_eSprite(&tft);
// 16bpp, not 8: roundRect anti-aliases its corners by pushing 16-bit spans,
// which an 8bpp sprite cannot take. Two bytes a pixel means smaller bands,
// which the painter already assumes.
buffer->setColorDepth(16);
if (!buffer->createSprite(w, h)) {
delete buffer;
buffer = nullptr;
return false;
}
bufX = x;
bufY = y;
clipW = w;
clipH = h;
return true;
}
void Gui::present() {
if (!buffer)
return;
buffer->pushSprite(bufX, bufY);
buffer->deleteSprite();
delete buffer;
buffer = nullptr;
bufX = bufY = 0;
clipW = clipH = INT32_MAX;
}
// TFT_eSPI's viewport is the native scissor: it clips fills, pixels and text on both the
// panel and a sprite. A false vpDatum keeps the origin where it already is, so callers go
// on drawing in the space the band translated them into rather than a third one.
void Gui::setClip(int32_t x, int32_t y, int32_t w, int32_t h) {
out().setViewport(x - bufX, y - bufY, w, h, false);
}
// Restored explicitly rather than with resetViewport(), which reads TFT_eSPI::width() --
// not virtual, so on a sprite it answers the panel's width and would leave the clip wider
// than the band, which is how a write runs off the buffer and corrupts the heap.
void Gui::clearClip() {
if (buffer)
buffer->setViewport(0, 0, clipW, clipH, false);
else
tft.resetViewport();
}
void Gui::drawLine(int32_t x1, int32_t y1, int32_t x2, int32_t y2,
int32_t color, int32_t width) {
x1 -= bufX;
x2 -= bufX;
y1 -= bufY;
y2 -= bufY;
if (width <= 1) {
tft.drawLine(x1, y1, x2, y2, color);
out().drawLine(x1, y1, x2, y2, color);
return;
}
tft.drawWideLine(x1, y1, x2, y2, static_cast<float>(width), color, color);
out().drawWideLine(x1, y1, x2, y2, static_cast<float>(width), color, color);
}
void Gui::drawCircle(int32_t x, int32_t y, int32_t radius, int32_t color,
@@ -161,14 +260,14 @@ void Gui::drawCircle(int32_t x, int32_t y, int32_t radius, int32_t color,
for (int32_t ring = 0; ring < (width < 1 ? 1 : width); ring++) {
const int32_t r = radius - ring;
if (r > 0)
tft.drawCircle(x, y, r, color);
out().drawCircle(x - bufX, y - bufY, r, color);
}
}
void Gui::fillCircle(int32_t x, int32_t y, int32_t radius, int32_t color,
const int32_t* background) {
tft.fillSmoothCircle(x, y, radius, color,
background ? *background : TFT_WHITE);
out().fillSmoothCircle(x - bufX, y - bufY, radius, color,
background ? *background : TFT_WHITE);
}
void Gui::roundRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t radius,
@@ -176,10 +275,10 @@ void Gui::roundRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t radius,
const int32_t* bottom, const int32_t* border) {
const uint16_t fillTop = top ? static_cast<uint16_t>(*top) : 0;
const uint16_t fillBottom = bottom ? static_cast<uint16_t>(*bottom) : fillTop;
paintRoundRect(tft, x, y, w, h, static_cast<float>(radius),
paintRoundRect(out(), x - bufX, y - bufY, w, h, static_cast<float>(radius),
static_cast<uint16_t>(background), top != nullptr, fillTop,
fillBottom, border != nullptr,
border ? static_cast<uint16_t>(*border) : 0);
border ? static_cast<uint16_t>(*border) : 0, clipW, clipH);
}
// Scanline fill: TFT_eSPI only offers triangles, and fanning a concave shape
@@ -217,8 +316,8 @@ void Gui::fillPolygon(const int32_t* xs, const int32_t* ys, size_t count,
}
}
for (size_t at = 0; at + 1 < found; at += 2) {
tft.drawFastHLine(crossings[at], y, crossings[at + 1] - crossings[at] + 1,
color);
out().drawFastHLine(crossings[at] - bufX, y - bufY,
crossings[at + 1] - crossings[at] + 1, color);
}
}
}
@@ -245,15 +344,13 @@ int32_t Gui::fontHeight(int32_t font, int32_t) const {
void Gui::drawText(int32_t font, int32_t x, int32_t y, const std::string& text,
int32_t color, int32_t, const int32_t* background) {
tft.setTextSize(scaleFor(font));
out().setTextSize(scaleFor(font));
if (background) {
tft.setTextColor(color, *background);
out().setTextColor(color, *background);
} else {
tft.setTextColor(color);
out().setTextColor(color);
}
tft.drawString(text.c_str(), x, y);
out().drawString(text.c_str(), x - bufX, y - bufY);
}
void Gui::setFullscreen(bool on) { host.setFullscreen(on); }
} // namespace slate
+2 -15
View File
@@ -18,22 +18,9 @@ void Log::write(esp32lua::LogLevel level, const std::string& message) {
Serial.printf("[lua] %s: %s\n", tag, message.c_str());
}
int32_t Settings::rotation() const { return settings.rotation; }
std::string Sys::timezone() const { return settings.timezone.c_str(); }
// Applies the rotation itself, because a setter that needs a follow-up call is
// a bug in the setter: the frame, the viewport and the bar all move together or
// not at all.
Status Settings::setRotation(int32_t degrees) {
if (!settings.setRotation(degrees))
return Status::failure("rotation must be 0, 90, 180 or 270");
host.applyRotation();
return settings.save() ? Status::success()
: Status::failure("cannot save settings");
}
std::string Settings::timezone() const { return settings.timezone.c_str(); }
Status Settings::setTimezone(const std::string& timezone) {
Status Sys::setTimezone(const std::string& timezone) {
if (timezone.empty() || timezone.size() > 48)
return Status::failure("timezone must be 1 to 48 characters");
settings.timezone = timezone.c_str();
+37 -11
View File
@@ -1,7 +1,3 @@
// Board bring-up and the loop. Everything about running a Lua app -- the state,
// the bindings, app loading, navigation history -- lives in the shared runtime
// behind LuaHost.
#include <SD.h>
#include <SPI.h>
#include <TFT_eSPI.h>
@@ -31,10 +27,10 @@ LuaHost host(tft, touch);
static bool halted = false;
static void fallbackScreen(const char* message) {
tft.resetViewport(); // no app, no status bar: this message owns the panel
tft.resetViewport();
tft.setRotation(0);
tft.fillScreen(TFT_WHITE);
tft.setTextSize(2);
tft.setTextSize(1);
tft.setTextColor(TFT_RED, TFT_WHITE);
tft.drawString(message, 10, 10);
tft.setTextColor(TFT_BLACK, TFT_WHITE);
@@ -43,11 +39,26 @@ static void fallbackScreen(const char* message) {
halted = true;
}
// Auto Dim
constexpr uint32_t kDimAfterMs = 10000;
constexpr int kBacklightChannel = 0;
uint32_t lastTouchMs = 0;
bool dimmed = false;
void updateBacklight() {
if (touch.touched())
lastTouchMs = millis();
bool idle = millis() - lastTouchMs > kDimAfterMs;
if (idle == dimmed)
return;
dimmed = idle;
ledcWrite(kBacklightChannel, dimmed ? 20 : 255);
}
void setup() {
Serial.begin(115200);
// A failed new otherwise unwinds to terminate() and a bare abort backtrace.
// Nothing can be freed at that point, so this only buys a legible cause.
// OOM Logging
std::set_new_handler([]() {
logHeap("oom");
Serial.println("[fatal] out of memory");
@@ -55,34 +66,49 @@ void setup() {
ESP.restart();
});
// Start TFT
tft.begin();
tft.setRotation(0);
tft.fillScreen(TFT_WHITE);
touchSpi.begin(14, 12, 13, TOUCH_CS);
touch.begin(touchSpi);
// TFT Dimming
ledcSetup(kBacklightChannel, 5000, 8);
ledcAttachPin(TFT_BL, kBacklightChannel);
ledcWrite(kBacklightChannel, 255);
lastTouchMs = millis();
// SD Card
sdSpi.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
if (!SD.begin(SD_CS, sdSpi)) {
fallbackScreen("SD card mount failed");
return;
}
settings.load(); // absent file keeps the built-in defaults
// Load Settings & Initialize Network
settings.load();
net::begin();
if (!host.begin())
fallbackScreen("home failed to start");
net::startWifi();
}
void loop() {
// Halted
if (halted) {
delay(100);
return;
}
// Auto Dim & Net / Lua Loop
updateBacklight();
net::loop();
host.loop();
// The launcher failing to start is the one error no app can recover from.
// Failed Lua Main
if (!host.running())
fallbackScreen("home failed to start");
// Yield
delay(1);
}
+19 -38
View File
@@ -11,13 +11,7 @@ namespace {
bool synced = false;
bool wasConnected = false;
volatile bool wifiShutdownPending = false;
// The device cannot be running before its own firmware was compiled, so the
// build timestamp is a safe floor. Certificate validity checks fail against
// 1970, and this makes them pass before SNTP has answered. mktime reads it as
// UTC while the build machine wrote local time; a few hours of error is
// irrelevant for a lower bound.
time_t buildTime() {
static const char months[] = "JanFebMarAprMayJunJulAugSepOctNovDec";
char month[4] = {__DATE__[0], __DATE__[1], __DATE__[2], '\0'};
@@ -32,13 +26,9 @@ time_t buildTime() {
return mktime(&parts);
}
// Latched, because sntp_get_sync_status() reports COMPLETED only briefly before
// resetting to wait for the next cycle; polling it would flap.
void onTimeSync(struct timeval*) {
const bool first = !synced;
synced = true;
if (first)
wifiShutdownPending = true;
Serial.printf("[net] clock synced: %lu\n", (unsigned long)time(nullptr));
}
@@ -50,43 +40,36 @@ void net::applyTimezone() {
}
void net::begin() {
// Apply Timezone
applyTimezone();
// Floor to Build
time_t floor = buildTime();
if (time(nullptr) < floor) {
struct timeval seed = {.tv_sec = floor, .tv_usec = 0};
settimeofday(&seed, nullptr);
}
// Latch
sntp_set_time_sync_notification_cb(onTimeSync);
WiFi.persistent(false);
}
// Deferred until the Lua runtime has allocated, so the driver's first init
// faces the same heap shape as every later one. Starting it against a pristine
// boot heap wins blocks that cannot be reassembled once Lua is resident.
void net::startWifi() {
if (settings.wifiSsid.isEmpty())
return;
logHeap("pre-wifi");
WiFi.mode(WIFI_STA);
WiFi.begin(settings.wifiSsid.c_str(), settings.wifiPassword.c_str());
logHeap("wifi-start");
}
void net::loop() {
if (wifiShutdownPending) {
wifiShutdownPending = false;
esp_sntp_stop();
WiFi.mode(WIFI_OFF);
wasConnected = false;
logHeap("wifi-off");
return;
}
void net::stopWifi() {
if (WiFi.getMode() == WIFI_MODE_NULL)
return;
// Stop WiFi
esp_sntp_stop();
WiFi.mode(WIFI_OFF);
wasConnected = false;
logHeap("wifi-off");
}
void net::loop() {
if (WiFi.getMode() == WIFI_MODE_NULL)
return;
// Store Connection
bool connected = WiFi.status() == WL_CONNECTED;
if (connected == wasConnected)
return;
@@ -94,11 +77,9 @@ 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(0, 0, "pool.ntp.org",
"time.nist.gov"); // UTC; the UI decides how to show it
configTzTime(settings.timezone.c_str(), "pool.ntp.org", "time.nist.gov");
Serial.println("[net] wifi up, sntp started");
}
+11 -4
View File
@@ -1,13 +1,20 @@
#pragma once
// Background network housekeeping: joining the saved network and keeping the
// clock honest. Nothing here blocks, so the UI keeps running while WiFi and
// SNTP settle.
// Network housekeeping around an on-demand radio. An app joins through
// wifi.connect() when it needs the network; nothing here auto-joins, so the
// radio is off while a Lua state is built. Nothing blocks, so the UI keeps
// running while SNTP settles.
namespace net {
void begin();
void startWifi();
// Powers the radio down now, synchronously. The launch path calls this before
// building the next app: WiFi and lwIP churn the heap as packets arrive, and a
// Lua state built alongside that churn fragments the one large block it needs.
// Idempotent - a no-op when the radio is already off.
void stopWifi();
void loop();
// Pushes settings.timezone into libc, so os.date() in Lua reports local time.
+2 -2
View File
@@ -69,8 +69,8 @@ bool Settings::load() {
lua_pcall(L, 0, 1, 0) == LUA_OK && lua_istable(L, -1);
if (ok) {
rotation = fieldOr(L, "rotation", rotation);
theme = stringFieldOr(L, "theme", theme);
timezone = stringFieldOr(L, "timezone", timezone);
theme = stringFieldOr(L, "theme", theme);
lua_getfield(L, -1, "touch");
if (lua_istable(L, -1)) {
touchX0 = fieldOr(L, "x0", touchX0);
@@ -92,8 +92,8 @@ bool Settings::load() {
bool Settings::save() const {
String source = "return {\n rotation = " + String(rotation) + ",\n";
source += " theme = " + luaString(theme) + ",\n";
source += " timezone = " + luaString(timezone) + ",\n";
source += " theme = " + luaString(theme) + ",\n";
source += " touch = { x0 = " + String(touchX0) +
", y0 = " + String(touchY0) + ", x1 = " + String(touchX1) +
", y1 = " + String(touchY1) + " },\n";
+3 -3
View File
@@ -11,13 +11,13 @@ struct Settings {
int16_t rotation = 0;
String wifiSsid;
String wifiPassword;
// Name of a palette in /lib/theme.lua; the colors themselves live on the
// card, so improving a theme never has to migrate saved settings.
String theme = "light";
// POSIX TZ string, applied with setenv("TZ")/tzset(). Storing the rule rather
// than a zone name means newlib handles DST changeovers and an unlisted zone
// still works.
String timezone = "UTC0";
// Name of a palette in ui.lua; the colors themselves live in Lua, so
// improving a theme never has to migrate saved settings.
String theme = "light";
uint8_t rotationIndex() const { return (rotation / 90) & 3; }
bool setRotation(int16_t degrees);
+4 -6
View File
@@ -1,14 +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()
dofile "sdcard/.lua/apps/Settings/main.lua"
local app = device.start "sdcard/.lua/apps/Settings/Ble/main.lua"
init()
device.tap "BLE"
assert(device.labelled "off")
assert(not device.find "disconnect")
@@ -16,12 +14,12 @@ device.tap "turn on"
assert(device.bleName == "Slate32 BLE")
device.tap "scan devices"
assert(device.labelled "scanning BLE")
tick()
app.tick()
assert(device.labelled "Sensor")
device.tap "Sensor"
assert(device.labelled "connecting BLE")
tick()
app.tick()
assert(device.bleConnected == "aa:bb:cc:dd:ee:ff")
assert(device.find "disconnect")
+59 -54
View File
@@ -14,22 +14,19 @@ local device = {
theme = "light",
clockSynced = false,
tickInterval = 0,
appName = "test",
textSize = 1,
fullscreen = false,
timezone = "UTC0",
raw = nil, -- pending raw touch reading, {x, y} or nil
networks = {}, -- what wifi.scan() returns
bleDevices = {}, -- what ble.scan() returns
status = { state = "disconnected", ssid = "", ip = "", rssi = 0 },
painted = {}, -- every drawText call, in order
calibration = nil, -- last settings.setCalibration()
calibration = nil, -- last touch.setCalibration()
freeHeap = 200000,
totalHeap = 320000,
largestBlock = 100000,
connected = nil, -- last wifi.connect()
backed = false,
launched = nil, -- last sys.launch()/replace(), {path, arg, replace}
started = nil, -- last sys.startApp(), {path, args}
saveFails = false, -- make every persisting call report failure
}
@@ -53,9 +50,10 @@ function device.install()
local nodes = {}
device.nodes = nodes
device.drawn = 0
device.invalidated = {}
device.tapTarget = nil
node = setmetatable({
tree = setmetatable({
reset = function()
for index in ipairs(nodes) do
nodes[index] = nil
@@ -67,9 +65,16 @@ function device.install()
label = spec.label,
kind = spec.type,
interactive = spec.interactive or spec.capture,
pressed = false,
}
return #nodes
end,
setPressed = function(id, pressed)
nodes[id].pressed = pressed and true or false
end,
isPressed = function(id)
return nodes[id].pressed
end,
attach = function(parent, child)
nodes[child].parent = parent
end,
@@ -99,13 +104,18 @@ function device.install()
draw = function()
device.drawn = device.drawn + 1
end,
-- Which nodes were asked to repaint, so a test can assert that a tick touched the
-- clock and nothing else.
invalidate = function(id)
device.invalidated[#device.invalidated + 1] = id
end,
}, {
__index = function()
return function() end
end,
})
gui = {
screen = {
-- Font roles are scales of the one built-in font, matching the firmware's GuiProvider.
FONT_SMALL = 1,
FONT_UI = 2,
@@ -149,13 +159,21 @@ function device.install()
return #text * device.charWidth * (font or 1)
end,
setRotation = function(degrees)
if degrees % 90 ~= 0 or degrees < 0 or degrees > 270 then
return false
end
device.rotation = degrees
return saved()
end,
getRotation = function()
return device.rotation
end,
setFullscreen = function(on)
device.fullscreen = on and true or false
getTheme = function()
return device.theme
end,
setTheme = function(name)
device.theme = name
return saved()
end,
}
@@ -163,26 +181,12 @@ function device.install()
getMillis = function()
return device.now
end,
back = function()
device.backed = true
end,
getAppID = function()
return device.appName
end,
getAppTitle = function()
return device.appName
end,
setAppTitle = function(name)
device.appName = name
end,
getAppDataPath = function()
return "/.lua/data/" .. device.appName
end,
getAPIVersion = function()
return 1
end,
hasFeature = function(name)
return name == "touch"
return name == "touch" or name == "screen"
end,
getMemory = function()
return device.freeHeap, device.totalHeap, device.largestBlock
@@ -190,29 +194,6 @@ function device.install()
isClockSynced = function()
return device.clockSynced
end,
launch = function(path, arg)
device.launched = { path = path, arg = arg, replace = false }
end,
replace = function(path, arg)
device.launched = { path = path, arg = arg, replace = true }
end,
}
settings = {
getRotation = function()
return device.rotation
end,
setRotation = function(degrees)
if degrees % 90 ~= 0 or degrees < 0 or degrees > 270 then
return false
end
device.rotation = degrees
return saved()
end,
setCalibration = function(...)
device.calibration = { ... }
return saved()
end,
getTimezone = function()
return device.timezone
end,
@@ -220,29 +201,35 @@ function device.install()
device.timezone = tz
return saved()
end,
startApp = function(path, args)
device.started = { path = path, args = args }
end,
}
input = {
touch = {
isTouched = function()
return device.raw ~= nil
end,
getTouch = function()
getPoint = function()
if not device.raw then
return nil
end
return device.raw[1], device.raw[2]
end,
getRawTouch = function()
getRawPoint = function()
if not device.raw then
return nil
end
return device.raw[1], device.raw[2]
end,
setCalibration = function(...)
device.calibration = { ... }
return saved()
end,
}
fs = {
MAX_READ_BYTES = 65536,
-- ui.lua persists the theme through fs, so the files table is real storage.
readFile = function(path)
return device.files[path]
end,
@@ -412,12 +399,30 @@ end
-- answers the same box here as it does inside the widget, so a custom painter's own
-- geometry decides what was hit, exactly as it does on the panel.
function device.press(id, x, y)
local ui = require "ui"
device.tapTarget = id
on_touch_down(x, y)
on_touch_up(x, y)
ui.down(x, y)
ui.up(x, y)
device.tapTarget = nil
end
-- Loads and mounts an app the way /.lua/main.lua does on the panel, minus the chrome:
-- the bar is a node like any other and is asserted on its own.
---Mounts an app the way main.lua does, minus the bar. Navigation is reset first,
---because the firmware reaches an app through a fresh state and nothing carries over.
function device.start(path, arg)
local ui = require "ui"
local nav = require "nav"
nav.start { app = path:match "apps/(.+)/main%.lua$" or path, arg = arg }
local app = dofile(path)
device.app = app
if app.init then
app.init(arg)
end
ui.mount(app.node)
return app
end
function device.findKind(kind)
for id, entry in ipairs(device.nodes) do
if entry.kind == kind then
+2 -2
View File
@@ -3,10 +3,10 @@
-- 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"
local keyboard = require "ui.keyboard"
-- The board as ui.screen would place it: full width inside 8px of padding.
local RECT = { x = 8, y = 8, w = 304, h = 4 * 30 + 3 * 5 }
+2 -5
View File
@@ -6,14 +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()
dofile "sdcard/.lua/apps/Settings/main.lua"
init()
device.tap "WiFi"
device.start "sdcard/.lua/apps/Settings/Wifi/main.lua"
local drawnBefore = device.drawn
device.tap "scan networks"
+68 -60
View File
@@ -1,17 +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/"
dofile "sdcard/.lua/apps/Settings/main.lua"
-- 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
local tapRow = device.tap
-- 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
@@ -19,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 = 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)
@@ -28,88 +45,79 @@ 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 = 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"
init()
tapRow "Timezone"
assert(settings.getTimezone() == zones[2].tz, "timezone " .. settings.getTimezone())
-- The card's value is replaced in place rather than by rebuilding the screen, so the
-- other four cards keep the nodes they already had.
local beforeCycle = node.getCount()
assert(device.labelled(zones[2].name), "timezone card did not take its new value")
assert(node.getCount() == beforeCycle, "cycling the timezone rebuilt the screen")
tapRow "Timezone"
assert(settings.getTimezone() == zones[3].tz, "timezone " .. settings.getTimezone())
-- Rotation cycles through the four quarter turns and wraps back to 0.
init()
for _, expected in ipairs { 90, 180, 270, 0 } do
tapRow "Rotation"
assert(settings.getRotation() == expected, "rotation " .. settings.getRotation())
end
-- Calibration collects one sample per target and saves on the second release.
init()
tapRow "Calibrate"
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 }
tick()
calibrate.tick()
device.raw = nil
tick()
calibrate.tick()
device.raw = { s2.x, s2.y }
tick()
calibrate.tick()
device.raw = nil
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 }
init()
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 }
init()
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 }
init()
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.
init()
wifiApp = device.start(APP .. "Wifi/main.lua")
device.networks = { { ssid = "qemu", rssi = -25, secure = false } }
tapRow "WiFi"
tapRow "scan networks"
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.
init()
wifiApp = device.start(APP .. "Wifi/main.lua")
device.networks = { { ssid = "secure", rssi = -40, secure = true } }
tapRow "WiFi"
tapRow "scan networks"
tick()
tapRow "secure"
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
-- to the widget, which is what makes the two agree on where "q" is.
local keyboard = require "keyboard"
local keyboard = require "ui.keyboard"
local board = device.findKind "custom"
assert(board, "the secure network did not open a keyboard")
local boardRect = { x = 0, y = 0, w = 10000, h = 10000 }
+52 -63
View File
@@ -1,88 +1,77 @@
-- Run: lua test/statusbar_dirty.lua
-- Asserts the bar repaints only what changed, and that it notices the two things it can
-- only learn by looking: rotation and theme. 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
-- 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;lib/esp32-lua-api/lua/lib/?.lua;test/?.lua;" .. package.path
local device = require("fake_device").install()
local ui = require "ui"
local statusbar = require "statusbar"
local nav = require "nav"
local statusbar = require "ui.statusbar"
device.clockSynced = false
-- History is the arguments this state was started with, so a route to go back to is
-- stated the way main.lua would have received it.
nav.start { app = "Settings", history = { { app = "Home" } } }
-- Returns the labels drawn by one draw() call.
local function paint()
device.painted = {}
device.lines = {}
statusbar.draw(true, false)
local labels = {}
for _, entry in ipairs(device.painted) do
labels[#labels + 1] = entry.label
-- What main.lua builds, minus the app: chrome above, whatever is left below.
local function screen()
if not statusbar.isVisible() then
return ui.box { w = "fill", h = "fill" }
end
return labels
return ui.box { w = "fill", h = "fill", statusbar.node(), ui.box { w = "fill", h = "fill" } }
end
local function has(labels, want)
for _, label in ipairs(labels) do
if label == want then
return true
end
end
return false
ui.mount(screen)
local built = tree.getCount()
assert(device.labelled "--:--:--", "the bar built no clock")
assert(device.labelled "117kB", "the bar built no memory slot")
assert(device.labelled(nav.getTitle()), "the bar built no title")
local function tick()
device.invalidated = {}
statusbar.tick()
return #device.invalidated
end
local first = paint()
assert(has(first, device.appName), "first draw paints the app name")
assert(has(first, "--:--:--"), "first draw paints the clock")
assert(has(first, "117kB"), "first draw paints used memory")
assert(#device.lines == 5, "offline wifi adds one diagonal to the back chevron lines")
assert(#paint() == 0, "an unchanged bar paints nothing")
assert(tick() == 0, "an unchanged bar repaints nothing")
device.freeHeap = device.freeHeap - 32000
local memOnly = paint()
assert(#memOnly == 1 and memOnly[1] == "148kB", "only the memory slot repaints")
assert(tick() == 1, "only the memory slot repaints")
assert(device.labelled "148kB", "the memory slot kept its old text")
assert(tree.getCount() == built, "a field update rebuilt the screen")
device.status = { state = "connected", ssid = "x", ip = "", rssi = -50 }
assert(#paint() == 0 and #device.lines == 0, "connected signal bars have no cross")
device.status = { state = "disconnected", ssid = "", ip = "", rssi = 0 }
assert(#paint() == 0 and #device.lines == 1, "disconnected wifi draws one diagonal cross")
assert(tick() == 1, "the signal strength repaints the icon and nothing else")
assert(tick() == 0, "and settles again afterwards")
local beforePressed = #device.roundRects
statusbar.draw(true, true)
assert(#device.roundRects == beforePressed + 1, "pressing back repaints its button")
statusbar.draw(true, false)
-- A longer title needs a wider box, which only a rebuild can give it.
nav.setTitle "settings - wifi"
statusbar.tick()
assert(device.labelled "settings - wifi", "a renamed app did not reach the bar")
assert(tick() == 0, "and settles again afterwards")
-- Rotation and theme invalidate everything, because every slot moves or recolors.
device.rotation = 180
assert(has(paint(), device.appName), "rotation repaints the whole bar")
assert(#paint() == 0, "and settles again afterwards")
ui.setTheme "dark"
assert(has(paint(), device.appName), "a theme change repaints the whole bar")
-- An app renaming itself is a bar change like any other, with no invalidation call.
sys.setAppTitle "settings - wifi"
assert(has(paint(), "settings - wifi"), "a renamed app repaints the whole bar")
assert(#paint() == 0, "and settles again afterwards")
-- A name wide enough to reach the memory slot is clipped, not drawn over it.
sys.setAppTitle(string.rep("w", 60))
local clipped
for _, label in ipairs(paint()) do
if label:sub(1, 1) == "w" then
clipped = label
-- The back control is offered only when there is somewhere to go back to. It carries no
-- label, so it is counted as what it is: the one thing in the bar a finger can press.
local function pressable()
local count = 0
for _, entry in ipairs(device.nodes) do
if entry.interactive then
count = count + 1
end
end
return count
end
assert(clipped and #clipped < 60, "an over-long name is clipped, got " .. #(clipped or ""))
sys.setAppTitle "test"
-- Leaving fullscreen is the one change draw() cannot see: the app painted over the bar
-- while nothing about the bar's own state moved.
ui.setTheme "light"
paint()
assert(pressable() == 1, "the bar offered no back control with history behind it")
nav.start { app = "Settings" }
ui.rebuild()
assert(pressable() == 0, "the bar offered back with no history behind it")
-- Fullscreen is chrome choosing not to build itself, so the app's node is the whole tree.
statusbar.setFullscreen(true)
assert(not device.labelled "--:--:--", "the bar survived fullscreen")
statusbar.setFullscreen(false)
assert(has(paint(), device.appName), "leaving fullscreen repaints the whole bar")
assert(device.labelled "--:--:--", "the bar did not come back")
print "ok"