Compare commits
12 Commits
main
..
app-tables
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e9cede336 | |||
| 0c0365fe45 | |||
| b8572fb422 | |||
| 1adf121dc6 | |||
| 575f31e7ee | |||
| 97abf037b8 | |||
| 7bc9afae33 | |||
| 823a664af7 | |||
| 1aa3a59ca9 | |||
| a60d054359 | |||
| 0a620713df | |||
| b3b5a9b2a3 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
+1
-1
Submodule lib/esp32-lua-api updated: 291f0f20b5...1c469b38fb
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user