12 Commits

Author SHA1 Message Date
evan 6e9cede336 feat: scrolling 2026-08-06 09:05:14 -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
29 changed files with 1396 additions and 1146 deletions
+23 -17
View File
@@ -32,11 +32,12 @@ Use an executable `.e32r40t` script. Assert on the app path the firmware logs ra
#!/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
tap 85 127
wait-log "running Hello"
wait-log "\[lua\] .* started"
wait-idle 3
capture _scratch/emulator-app.png
```
@@ -50,27 +51,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 +80,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.
@@ -127,7 +133,7 @@ the submodule and are not committed here. Directory contents overwrite matching
## 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
+121 -40
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,25 +70,61 @@ 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 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()`.
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)
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
**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.
**`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. `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.
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.
`sdcard/lib/keyboard.lua` 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`,
@@ -99,33 +136,72 @@ 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
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. Keep ordinary toolkit UIs
frame-sized. If a real long-list use case appears, prefer one purpose-built painter over a
component per row.
## 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".
`sdcard/.lua/lib/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 +217,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.
+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.
+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
+17 -16
View File
@@ -1,15 +1,25 @@
local ui = require "ui"
local keyboard = require "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
+67
View File
@@ -0,0 +1,67 @@
local ui = require "ui"
-- A scrolling list with no band management of its own: the painter composites the dirty
-- region into offscreen bands and pushes each once, so this just draws every visible row in
-- screen coordinates and invalidates on each tick. The rows straddle band edges freely --
-- roundRect's corner spans clip to the band in the driver, so a split row is drawn correctly
-- in each half.
local ROWS = 60
local ROW_H = 64
local GAP = 8
local FONT = screen.FONT_LARGE
local SPEED = 6 -- pixels per 20ms tick
---@class ScrollApp : SlateApp
local M = {}
local node
local area
local scrollY = 0
local velocity = SPEED
local function render(x, y, w, h)
area = { x = x, y = y, w = w, h = h }
local textH = screen.getFontHeight(FONT)
for i = 0, ROWS - 1 do
local top = y + i * ROW_H - scrollY
if top + ROW_H > y and top < y + h then
local fill = (i % 2 == 0) and ui.theme.face or ui.theme.background
screen.roundRect(x + GAP, top + GAP, w - 2 * GAP, ROW_H - 2 * GAP,
ui.theme.radius, ui.theme.background, fill, nil, ui.theme.muted)
local label = "Row " .. (i + 1)
local tx = x + (w - screen.getTextWidth(FONT, label)) // 2
screen.drawText(FONT, tx, top + (ROW_H - textH) // 2, label, ui.theme.color)
end
end
end
function M.init()
timer.every(20, function()
if not area then
return
end
scrollY = scrollY + velocity
local maxScroll = ROWS * ROW_H - area.h
if scrollY <= 0 then
scrollY = 0
velocity = math.abs(velocity)
elseif scrollY >= maxScroll then
scrollY = maxScroll
velocity = -math.abs(velocity)
end
ui.invalidate(node)
end)
log.info "scroll demo ready"
end
function M.node()
node = ui.custom {
w = "fill",
h = "fill",
paint = function(_, x, y, w, h)
render(x, y, w, h)
end,
}
return node
end
return M
+285 -285
View File
@@ -1,28 +1,38 @@
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 zones = require "timezones"
local FONT = screen.FONT_UI
local INSET = 30
local MENU_PAD, MENU_GAP = 12, 8
local screen, message, passwordLabel, passwordRow
---@class SettingsApp : SlateApp
local M = {}
-- Every screen this app has is a value of `mode`, and node() builds whichever one is
-- current. Nothing is retained between them, so a rotation, a theme change and a tap that
-- navigates are all the same operation: set the state, rebuild.
local mode = "menu"
local message, bleMessage, busyText
local dialog
local networks, bleDevices = {}, {}
local samples, pending, armed = {}, nil, false
local scanRequested, keyboardRequested = false, false
local bleScanRequested, bleConnectRequested = false, false
local selectedNetwork, password = nil, ""
local selectedBleDevice
local bleMessage
local timezoneCard
local targetIndex = 1
local rotationBeforeCalibration = 0
local passwordLabel
local networkOf, bleDeviceOf = {}, {}
local buildMenu, buildWifi, buildNetworks, buildKeyboard, buildBle, buildBleDevices
local startCalibration, cycleRotation, updatePassword
local function show(next)
mode = next
ui.rebuild()
end
-- 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)
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),
@@ -31,23 +41,6 @@ function computeCalibration(s1, s2, w, h, 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
@@ -55,40 +48,42 @@ local function target(n)
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.
-- The one painter in the app: a cross has no widget, and the panel underneath must stay
-- in physical coordinates while the samples are read.
local function paintTarget(_, ox, oy)
local x, y = target(targetIndex)
local theme = ui.theme
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)
screen.drawText(FONT, ox + 10, oy + 10, "tap the cross", theme.color, nil, theme.background)
screen.drawLine(x - 12, y, x + 12, y, theme.accent)
screen.drawLine(x, y - 12, x, y + 12, theme.accent)
end
local function finishCalibration()
local ok = settings.setCalibration(computeCalibration(samples[1], samples[2], 320, 480, INSET))
local ok = touch.setCalibration(M.computeCalibration(samples[1], samples[2], 320, 480, INSET))
message = ok and "calibration saved" or "save failed"
screen.setRotation(rotationBeforeCalibration)
mode = "menu"
gui.setRotation(settings.getRotation())
statusbar.setFullscreen(false)
buildMenu()
statusbar.setFullscreen(false) -- rebuilds, which is what puts the menu back
end
function startCalibration()
-- Rotation is saved as it is applied, so calibrating upright is a change the app
-- has to put back itself once the samples are in.
local function startCalibration()
message = nil
mode = "calibrate"
samples, pending, armed = {}, nil, false
gui.setRotation(0)
targetIndex = 1
mode = "calibrate"
rotationBeforeCalibration = screen.getRotation()
screen.setRotation(0)
-- The targets sit at the physical corners and the samples are read in panel
-- coordinates, so the status bar cannot be allowed to shift the frame.
statusbar.setFullscreen(true)
drawTarget(1)
end
function cycleRotation()
local ok = settings.setRotation((settings.getRotation() + 90) % 360)
local function cycleRotation()
local ok = screen.setRotation((screen.getRotation() + 90) % 360)
message = ok and "rotation saved" or "save failed"
buildMenu()
ui.rebuild()
end
local function cycleTheme()
@@ -101,15 +96,13 @@ local function cycleTheme()
end
local ok = ui.setTheme(names[next_index])
message = ok and "theme saved" or "save failed"
buildMenu()
ui.rebuild()
end
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 function zoneLabel()
local current = settings.getTimezone()
local current = sys.getTimezone()
for _, zone in ipairs(zones) do
if zone.tz == current then
return zone.name
@@ -119,22 +112,127 @@ local function zoneLabel()
end
local function cycleTimezone()
local current = settings.getTimezone()
local current = sys.getTimezone()
local next_index = 1
for index, zone in ipairs(zones) do
if zone.tz == current then
next_index = index % #zones + 1
end
end
local ok = settings.setTimezone(zones[next_index].tz)
ui.setText(timezoneValue, ok and zoneLabel() or "save failed")
ui.invalidate(timezoneCard)
message = sys.setTimezone(zones[next_index].tz) and nil or "save failed"
ui.rebuild()
end
local function wifiValue(status)
return status.state == "connected" and "on" or "off"
end
-- Announced rather than left to draw(): the work these stand in for blocks the loop, and
-- tick() runs before draw in the same pass, so the panel would sit on the background the
-- rebuild cleared it to until the blocking call returned.
local function busy(text)
busyText = text
show "busy"
end
local function requestScan()
scanRequested = true
busy "scanning networks..."
end
local function forgetNetwork()
dialog = nil
message = wifi.forget() and "wifi forgotten" or "save failed"
show "wifi"
end
local function dismissDialog()
dialog = nil
ui.rebuild()
end
-- Destructive and one tap away from the row above it, which is what a confirm is for.
local function confirmForget()
dialog = "forget"
show "wifi"
end
local function connectSavedNetwork()
if not wifi.connect() then
message = "could not connect wifi"
return show "wifi"
end
show "connecting"
end
local function connectSelected()
if not wifi.connect(selectedNetwork.ssid, password) then
message = "could not save wifi"
return show "wifi"
end
show "connecting"
end
local function disconnectWifi()
wifi.disconnect()
message = "wifi off"
show "wifi"
end
local function chooseNetwork(network)
selectedNetwork, password = { ssid = network.ssid }, ""
if not network.secure then
return connectSelected()
end
keyboardRequested = true
busy "opening keyboard..."
end
local function selectNetwork(id)
chooseNetwork(networkOf[id])
end
local function enableBle()
local ok, err = ble.init "Slate32 BLE"
bleMessage = ok and "BLE on" or err
show "ble"
end
local function disableBle()
ble.deinit()
bleMessage = "BLE off"
show "ble"
end
local function disconnectBle()
ble.disconnect()
bleMessage = "BLE disconnected"
show "ble"
end
local function startBleAdvertising()
local ok, err = ble.startAdvertising "Slate32 BLE"
bleMessage = ok and "BLE advertising" or err
show "ble"
end
local function stopBleAdvertising()
ble.stopAdvertising()
bleMessage = "BLE advertising stopped"
show "ble"
end
local function requestBleScan()
bleScanRequested = true
busy "scanning BLE..."
end
local function selectBleDevice(id)
selectedBleDevice = bleDeviceOf[id]
bleConnectRequested = true
busy "connecting BLE..."
end
local function card(side, title, value, on_click)
local spec = {
w = side,
@@ -143,34 +241,30 @@ local function card(side, title, value, on_click)
justify = "center",
align = "center",
on_click = on_click,
ui.label(title, { font = gui.FONT_UI, fit = side - 12 }),
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"
local function menuScreen()
-- No title: the status bar already names the running app. The status line under the
-- grid is worth its rows, so the cards give it room rather than pushing it off screen.
local side, cols = ui.cardSide(6, MENU_PAD, MENU_GAP, message and 20 or 0)
local cards = {
card(side, "Calibrate", "touch", startCalibration),
card(side, "Rotation", settings.getRotation() .. " deg", cycleRotation),
card(side, "WiFi", wifiValue(wifi.getStatus()), buildWifi),
card(side, "BLE", ble.isInitialized() and "on" or "off", buildBle),
card(side, "Rotation", screen.getRotation() .. " deg", cycleRotation),
card(side, "WiFi", wifiValue(wifi.getStatus()), function()
show "wifi"
end),
card(side, "BLE", ble.isInitialized() and "on" or "off", function()
show "ble"
end),
card(side, "Theme", ui.getTheme(), cycleTheme),
card(side, "Timezone", zoneLabel(), cycleTimezone),
}
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" }
@@ -185,68 +279,16 @@ function buildMenu()
if message then
items[#items + 1] = ui.text(message)
end
themed(items)
return 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
local function backTo(next)
return function()
show(next)
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 function wifiScreen()
local status = wifi.getStatus()
local items = { pad = 12, gap = 8, ui.text "wifi", ui.text(wifiValue(status)) }
if status.state == "connected" then
@@ -262,47 +304,22 @@ function buildWifi()
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)
items[#items + 1] = ui.button { label = "back", on_click = backTo "menu" }
return items
end
local function connectSelected()
if not wifi.connect(selectedNetwork.ssid, password) then
message = "could not save wifi"
buildWifi()
return
end
mode = "connecting"
themed {
local function connectingScreen()
local to = selectedNetwork and (" to " .. selectedNetwork.ssid) or ""
return {
pad = 12,
gap = 8,
ui.text "wifi",
ui.text("connecting to " .. selectedNetwork.ssid .. "..."),
ui.button { label = "back", on_click = buildWifi },
ui.text("connecting" .. to .. "..."),
ui.button { label = "back", on_click = backTo "wifi" },
}
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 function networksScreen()
local byName = {}
for _, network in ipairs(networks) do
local current = byName[network.ssid]
@@ -319,101 +336,32 @@ function buildNetworks(networks)
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 card = ui.button {
local button = ui.button {
label = network.ssid .. lock .. " " .. network.rssi,
on_click = selectNetwork,
}
networkOf[card] = network
items[#items + 1] = card
-- The network a card stands for, keyed by node id: a node is sixteen bytes in the
-- firmware and carries nothing an app puts on it.
networkOf[button] = network
items[#items + 1] = button
end
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)
items[#items + 1] = ui.button { label = "back", on_click = backTo "wifi" }
return 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 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 = gui.FONT_SMALL })
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 }
@@ -426,74 +374,122 @@ function buildBle()
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)
items[#items + 1] = ui.button { label = "back", on_click = backTo "menu" }
return items
end
function updatePassword(value)
password = value
ui.setText(passwordLabel, "password: " .. password)
ui.invalidate(passwordRow)
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
function buildKeyboard()
screen = nil
collectgarbage()
local function passwordScreen()
local keyboard = require "keyboard"
mode = "password"
passwordLabel = ui.text("password: " .. password)
passwordRow = ui.box { passwordLabel }
themed {
passwordLabel = ui.text("password: " .. password, { w = "fill" })
return {
pad = 8,
gap = 5,
ui.text(selectedNetwork.ssid),
passwordRow,
passwordLabel,
keyboard.new {
value = password,
on_change = updatePassword,
on_change = function(value)
password = value
ui.setText(passwordLabel, "password: " .. password)
end,
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()
local SCREENS = {
menu = menuScreen,
wifi = wifiScreen,
connecting = connectingScreen,
networks = networksScreen,
ble = bleScreen,
ble_devices = bleDevicesScreen,
password = passwordScreen,
busy = function()
return { w = "fill", h = "fill", justify = "center", align = "center", ui.label(busyText) }
end,
}
function M.node()
if mode == "calibrate" then
return ui.custom { w = "fill", h = "fill", paint = paintTarget }
end
-- The content box carries the padding so a dialog can cover the whole panel. The scrim
-- is a style on the box holding the content, so it reaches the margins the content does
-- not while the dialog above it keeps the lit palette.
local content = ui.box(SCREENS[mode]())
if not dialog then
return ui.box { w = "fill", h = "fill", content }
end
return ui.box {
w = "fill",
h = "fill",
ui.box { w = "fill", h = "fill", color = ui.theme.muted, face = ui.theme.background, content },
ui.confirm {
title = "forget network?",
message = wifi.getStatus().ssid,
ok = "forget",
on_ok = forgetNetwork,
on_cancel = dismissDialog,
},
}
end
function M.init()
timer.every(50, M.tick) -- calibration samples the raw panel between draws
log.info "settings ready"
end
function 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()
function M.tick()
if keyboardRequested then
keyboardRequested = false
buildKeyboard()
show "password"
local free, _, largest = sys.getMemory()
log.info("wifi keyboard ready free=" .. free .. " largest=" .. largest)
return
end
if scanRequested then
scanRequested = false
buildNetworks(wifi.scan())
-- A scan that cannot start reports why: the radio needs a contiguous buffer, and a
-- crash here would take the app down over a condition the next attempt may not hit.
local found, err = wifi.scan()
if not found then
message = err or "scan failed"
log.error("wifi scan failed: " .. tostring(err))
show "wifi"
return
end
networks = found
log.info("wifi scan found " .. #networks .. " networks")
show "networks"
return
end
if bleScanRequested then
@@ -501,10 +497,11 @@ function tick()
local devices, err = ble.scan(3000)
if devices then
log.info("BLE scan found " .. #devices .. " devices")
buildBleDevices(devices)
bleDevices = devices
show "ble_devices"
else
bleMessage = err
buildBle()
show "ble"
end
return
end
@@ -512,7 +509,7 @@ function tick()
bleConnectRequested = false
local ok, err = ble.connect(selectedBleDevice.address)
bleMessage = ok and "BLE connected" or err
buildBle()
show "ble"
return
end
if mode == "connecting" then
@@ -521,11 +518,11 @@ function tick()
message = "connected: " .. status.ip
local free, _, largest = sys.getMemory()
log.info("wifi connected " .. status.ssid .. " free=" .. free .. " largest=" .. largest)
buildWifi()
show "wifi"
elseif status.state == "failed" or status.state == "not_found" then
message = "connection " .. status.state
log.info("wifi connection " .. status.state)
buildWifi()
show "wifi"
end
return
end
@@ -533,7 +530,7 @@ function tick()
return
end
local rx, ry = input.getRawTouch()
local rx, ry = touch.getRawPoint()
if not armed then
if not rx then
armed = true
@@ -546,9 +543,12 @@ function tick()
samples[#samples + 1] = pending
pending = nil
if #samples == 1 then
drawTarget(2)
targetIndex = 2
ui.rebuild()
else
finishCalibration()
end
end
end
return M
+15 -15
View File
@@ -1,5 +1,5 @@
local ui = require "ui"
local FONT = gui.FONT_UI
local FONT = screen.FONT_UI
local M = {}
@@ -106,13 +106,13 @@ 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)
screen.drawLine(middle, y + 8, middle, y + 20, color)
screen.drawLine(middle - 5, y + 15, middle, y + 20, color)
screen.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)
screen.drawLine(middle, y + 9, middle, y + 21, color)
screen.drawLine(middle - 5, y + 14, middle, y + 9, color)
screen.drawLine(middle, y + 9, middle + 5, y + 14, color)
end
end
@@ -120,14 +120,14 @@ 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)
screen.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(
screen.drawText(
FONT,
x + (width - gui.getTextWidth(FONT, label)) // 2,
y + (KEY_H - gui.getFontHeight(FONT)) // 2,
x + (width - screen.getTextWidth(FONT, label)) // 2,
y + (KEY_H - screen.getFontHeight(FONT)) // 2,
label,
color,
nil,
@@ -172,7 +172,7 @@ function M.eachKey(page, rect, visit)
end
local function rectOf(id)
local x, y, w, h = node.getRect(id)
local x, y, w, h = tree.getRect(id)
return { x = x, y = y, w = w, h = h }
end
@@ -230,13 +230,13 @@ local function press(id, x, y)
end
elseif action == "shift" then
st.page = st.page == "lower" and "upper" or "lower"
node.invalidate(id)
tree.invalidate(id)
elseif action == "symbols" then
st.page = st.page == "numbers" and "symbols" or "numbers"
node.invalidate(id)
tree.invalidate(id)
elseif action == "mode" then
st.page = (st.page == "lower" or st.page == "upper") and "numbers" or "lower"
node.invalidate(id)
tree.invalidate(id)
elseif action == "backspace" then
st.value = st.value:sub(1, -2)
changed()
+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
+127 -117
View File
@@ -1,31 +1,26 @@
-- 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.
-- The top strip, built into the same tree as the running app by /.lua/main.lua.
--
-- 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.
-- 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 FONT = gui.FONT_SMALL
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 = gui.FONT_UI
local TITLE_FONT = screen.FONT_UI
local PAD = 6
local BAR_H = 44
local PAD, GAP = 6, 8
local WIFI_W, WIFI_HEIGHTS = 11, { 3, 5, 8 }
-- 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
local M = { height = BAR_H }
-- 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 ids = {}
local title, shownBars, shownOffline
local hidden = false
local function clock()
return sys.isClockSynced() and os.date "%H:%M:%S" or "--:--:--"
@@ -45,125 +40,140 @@ local function memory()
return (used + 512) // 1024 .. "kB"
end
local function drawWifi(x, y, bars, offline, theme)
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
gui.fillRect(x + (i - 1) * 4, y + 8 - h, 3, h, i <= bars and theme.color or empty)
screen.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)
screen.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
-- 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
-- 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
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
gui.drawLine(x + offset, y - 4, x + offset - 4, y, color)
gui.drawLine(x + offset - 4, y, x + offset, y + 4, color)
screen.drawLine(cx + offset, cy - 4, cx + offset - 4, cy, color)
screen.drawLine(cx + offset - 4, cy, cx + offset, cy + 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
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
function M.draw(hasBack, backPressed)
---@return NodeId
function M.node()
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),
}
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
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)
if nav.getTitle() ~= title then
ui.rebuild()
return
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
ui.setText(ids.clock, clock())
ui.setText(ids.memory, memory())
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)
if bars ~= shownBars or offline ~= shownOffline then
shownBars, shownOffline = bars, offline
ui.invalidate(ids.wifi)
end
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)
---@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
ui.rebuild()
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 "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()
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
+12 -149
View File
@@ -1,43 +1,10 @@
#include "lua_host.h"
#include <algorithm>
#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 +13,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 +45,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 +90,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 +110,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;
@@ -281,12 +150,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;
+22 -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,28 @@ 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;
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 {
+113 -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,88 @@ 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;
}
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 +243,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 +258,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 +299,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 +327,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();
+39 -10
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,52 @@ 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 & Load Lua
settings.load();
net::begin();
if (!host.begin())
fallbackScreen("home failed to start");
// Start WiFi
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);
}
+2 -2
View File
@@ -97,8 +97,8 @@ void net::loop() {
// 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.
sntp_servermode_dhcp(1);
configTime(0, 0, "pool.ntp.org",
"time.nist.gov"); // UTC; the UI decides how to show it
// configTime() would overwrite TZ with UTC, so hand it the saved rule.
configTzTime(settings.timezone.c_str(), "pool.ntp.org", "time.nist.gov");
Serial.println("[net] wifi up, sntp started");
}
+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);
+3 -4
View File
@@ -5,9 +5,8 @@ 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/main.lua"
init()
device.tap "BLE"
assert(device.labelled "off")
assert(not device.find "disconnect")
@@ -16,12 +15,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
+1 -3
View File
@@ -10,9 +10,7 @@ package.path = "sdcard/.lua/lib/?.lua;test/?.lua;" .. package.path
local device = require("fake_device").install()
dofile "sdcard/.lua/apps/Settings/main.lua"
init()
device.start "sdcard/.lua/apps/Settings/main.lua"
device.tap "WiFi"
local drawnBefore = device.drawn
+32 -26
View File
@@ -6,9 +6,15 @@ package.path = "sdcard/.lua/lib/?.lua;test/?.lua;" .. package.path
local device = require("fake_device").install()
dofile "sdcard/.lua/apps/Settings/main.lua"
local SETTINGS = "sdcard/.lua/apps/Settings/main.lua"
local app = device.start(SETTINGS)
local tapRow = device.tap
-- Relaunching is how a test gets a clean screen, because that is what the firmware does:
-- a new app is a new state that builds itself from nothing.
local function restart()
app = device.start(SETTINGS)
end
-- A perfectly linear panel spanning raw 200..3800 over 320x480 must round-trip
-- to those same extremes from the two inset samples.
@@ -19,7 +25,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 = app.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,83 +34,83 @@ 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 = app.computeCalibration(s3, s4, w, h, inset)
assert(fx0 > fx1, "flipped axis should descend")
-- Timezone cycles through the picker list.
local zones = require "timezones"
init()
restart()
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(sys.getTimezone() == zones[2].tz, "timezone " .. sys.getTimezone())
-- Cycling rebuilds the screen, which is the only way a screen changes now, so the card
-- shows the new zone and the menu is still the same size.
local beforeCycle = tree.getCount()
assert(device.labelled(zones[2].name), "timezone card did not take its new value")
assert(node.getCount() == beforeCycle, "cycling the timezone rebuilt the screen")
assert(tree.getCount() == beforeCycle, "the rebuilt menu grew")
tapRow "Timezone"
assert(settings.getTimezone() == zones[3].tz, "timezone " .. settings.getTimezone())
assert(sys.getTimezone() == zones[3].tz, "timezone " .. sys.getTimezone())
-- Rotation cycles through the four quarter turns and wraps back to 0.
init()
restart()
for _, expected in ipairs { 90, 180, 270, 0 } do
tapRow "Rotation"
assert(settings.getRotation() == expected, "rotation " .. settings.getRotation())
assert(screen.getRotation() == expected, "rotation " .. screen.getRotation())
end
-- Calibration collects one sample per target and saves on the second release.
init()
restart()
tapRow "Calibrate"
tick() -- release after the menu tap arms sampling
app.tick() -- release after the menu tap arms sampling
device.raw = { s1.x, s1.y }
tick()
app.tick()
device.raw = nil
tick()
app.tick()
device.raw = { s2.x, s2.y }
tick()
app.tick()
device.raw = nil
tick()
app.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.
device.status = { state = "disconnected", ssid = "", ip = "", rssi = 0 }
init()
restart()
tapRow "WiFi"
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()
restart()
tapRow "WiFi"
assert(device.find "connect" and not device.find "disconnect", "configured wifi can reconnect")
tapRow "connect"
assert(device.wifiReconnect, "wifi reconnect should use saved credentials")
device.status = { state = "connected", ssid = "saved", ip = "192.168.1.2", rssi = -40 }
init()
restart()
tapRow "WiFi"
assert(device.find "disconnect" and not device.find "connect", "connected wifi can disconnect")
tapRow "disconnect"
assert(device.status.state == "disconnected" and device.status.ssid == "saved", "disconnect preserves wifi intent")
-- Open networks connect directly from scan results.
init()
restart()
device.networks = { { ssid = "qemu", rssi = -25, secure = false } }
tapRow "WiFi"
tapRow "scan networks"
tick()
app.tick()
tapRow "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()
restart()
device.networks = { { ssid = "secure", rssi = -40, secure = true } }
tapRow "WiFi"
tapRow "scan networks"
tick()
app.tick()
tapRow "secure"
tick()
app.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
+50 -61
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.
-- The bar is part of the tree, so a tick has to repaint the field that changed and leave
-- the rest of the screen alone. The clock is pinned unsynced so its text is constant and
-- every other field can be steered on its own.
package.path = "sdcard/.lua/lib/?.lua;test/?.lua;" .. package.path
local device = require("fake_device").install()
local ui = require "ui"
local nav = require "nav"
local statusbar = require "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"