on_tick runs before draw in the same pass, so a screen built for a blocking call was
never painted until the call returned -- on a panel ui.screen() had already cleared.
Scanning looked like a hang on a blank screen. The notice is now painted by the tap
itself and centered on both axes.
Reading a module into one String needs that many bytes contiguous. Once WiFi is up the
largest free block drops to ~40KB, so loading /lib/ui.lua failed -- and readString()
reports allocation failure by silently returning a partial read, which reached Lua as a
syntax error at a random offset. Every app launch was broken while connected.
loadScript() now feeds lua_load() through a 512 byte buffer, so no module needs a
contiguous allocation. readScript(), still used by fs.readFile, verifies the length it
got against the file size rather than trusting it. The launch log carries free and
largest-block so the next heap question is answerable without a rebuild.
The bar repainted itself whole every second. It now compares each field against what
it last painted, adds seconds and a memory percentage, and keys the cache on
gui.getRotation() and ui.themeName so rotation and theme changes still repaint it.
Invalidation lives entirely in Lua; the firmware's push flag and gfx/statusbar.h are gone.
Bindings follow getName/setName/isName, persisted preferences move from sys to a settings
table, and gui.setRotation takes degrees like settings does. A bar that dies mid-run now
keeps its rows reserved rather than silently resizing the running app.
Leaving an app was the app's own responsibility, so one that shipped without
an exit could only be escaped with a reset. The bar now paints a back button
into its leading square and the firmware treats that rect as home, acting on
release so a press sliding into the app cancels. The app it returns to is
/apps/home, which is what it is to the user.
Card grids in home and settings centre left to right as a unit.
gui.drawText painted a flat rectangle behind every glyph, so a label on a
gradient card sat in a patch matching only the one gradient row its colour
came from. Omitting the background colour now draws transparent glyphs, and
buttons pass none: the face is repainted above them on every change, so the
label has nothing to erase.
The bar is host-owned chrome: the firmware clips apps into a viewport below
it, so no app can paint over it, while /lib/statusbar.lua owns the height,
repaint interval and painting. gui.fullscreen() lets touch calibration take
the physical panel back.
Launcher and settings become grids of square cards (3 across landscape, 2
portrait) via the new gui.setTextSize, ui.label and ui.cardSide. The one
function on the `app` table moves to sys.setTickInterval, alongside the new
sys.appName the bar needs.
TFT_eSPI drives the backlight pin only when TFT_BL and TFT_BACKLIGHT_ON are both
defined. Only the first was, so GPIO27 was never even configured as an output: the
panel initialised, took every pixel it was sent and stayed dark.
The emulator cannot catch this. It models the framebuffer, not the lamp, so a board
with no backlight and a board with a perfect one render identically.
esp32-lcd named the substrate rather than the thing, and both halves would age: the
chip is swappable and the panel technology is incidental. What the project actually is
is a Lua app platform that happens to run on a cheap touchscreen, so the name is now a
model number rather than a parts list. slate32 is also unclaimed, where slate alone
collides with several well known projects.
Board identifiers stay as they were -- the e32r40t QEMU machine, the esp32-32e build
env and the test skill name all refer to real hardware, which did not get renamed.
The panel has no alpha and there is no framebuffer to blend against -- 307KB against
276KB free -- so a scrim cannot be composited. It can be computed instead: the palette
is re-derived from seeds mixed toward black, and the content behind a dialog is
repainted in those colors, which is the result a black scrim would have produced. The
tree was already the source of truth, so dimming is a palette swap rather than a
readback. A node marked dimmed carries it to everything it contains, and ui.confirm
opts its card back out.
Doing that exposed a latent bug in bordered boxes. They filled a square rect and then
drew a rounded border over it, so the corners the border does not cover showed the
fill. That was invisible while everything behind a card was the same color as the card,
and obvious the moment the background dimmed. Separating the two roles fixes it: bg is
what a node fills, surface is what sits underneath, anti-aliased edges blend into
surface, and a box that paints its own rounded background suppresses the square fill.
A dialog is a component the app includes when its state says so, placed absolutely so
it covers the flow instead of joining it, and dismissed by rebuilding without it. No
layer stack, no module state, no lifecycle: "on top" already means "later in the child
list", which the draw walk gives for free, and nothing needs to survive a rebuild
because the tree is derived from state rather than mutated beside it.
The alternative was a ui.push/ui.pop stack of roots. It would have been the only
imperative thing in an otherwise declarative program, and needed rules to reconcile two
ways for something to reach the screen -- the rule that a rebuild must only replace the
base being the one that would eventually be forgotten.
Three primitives were missing and are now here. capture makes a component swallow the
taps its children missed, without which the hit walk falls back to earlier siblings and
a dialog can be tapped through. border draws a box as a rounded rect. fill names a size
a fraction cannot express, since any number >= 1 is read as pixels, so w = 1.0 asked
for one pixel.
The root now measures as "fill" rather than auto. It is placed at the full panel rect
already, so its own children could not resolve a fraction of the one component whose
size is never in doubt.
Settings asks before forgetting a network, which is the first caller.
The loop spun at full clock between polls, so the CPU was pegged no matter what an app
was doing. delay(1) hands the core to the idle task, which lets the WiFi and lwIP tasks
run promptly and lets real silicon clock-gate in WAITI. Touch reads are throttled to
3 ms by the driver and draws to 33 ms, so nothing observable slows down.
The halted path now idles at 100 ms rather than spinning on a state that cannot change
without a reset.
Not measurable in the emulator: QEMU does not model WAITI, so host CPU stays at 105%
either way. The gain is on hardware.
Assigning .label repainted nothing until the caller also remembered to invalidate, and
forgetting produced a screen that silently stopped updating with no error to follow.
setText makes the pair atomic and skips the repaint when the text is unchanged, so an
app can push a value on every tick without comparing first.
Both firmwares now return {state, ssid, ip, rssi} from wifi.status() and set the tick
interval through app.setTickInterval(), so the drift list is down to the filesystem
gaps and timers.
A magic global that the runtime reads once at startup could not be changed later, gave
no feedback when misspelled, and was a second spelling of a mechanism the sibling
firmware already had. app.setTickInterval(ms) clamps to 33..3600000, takes 0 to stop,
and errors when on_tick() is not defined -- by the time init() runs the chunk body has
finished, so a missing callback is a typo rather than a race.
Also drops the code comments pointing at the other repo. Where the two APIs agree or
differ belongs in docs/lua-api-parity.md; a comment beside a constant explaining that
another firmware picked the same number is noise a reader here cannot act on.
crosspoint-reader calls it init() and requires it; this called it setup() and treated
it as optional. Same concept, two spellings, so an app could not move between the two
firmwares for no reason worth defending. init() wins because it is also the stricter
contract: a misspelled entry point is now an error instead of an app that starts,
draws nothing, and explains nothing.
Requiring it exposed that error screens were unreadable. fail() painted the message
and the host relaunched the launcher over it on the very next frame, so every Lua
error was serial-only -- which would have made "Missing init()" useless to anyone
holding the device rather than a console.
The http table copies crosspoint-reader's signatures exactly -- get/head/delete/post/
patch returning (body|nil, status), download taking maxBytes/expectedSize/sha256, the
same 50000 byte body cap and the same -1 for a request that never left the device --
so a script that talks to a server runs on either firmware. docs/lua-api-parity.md
records that, and every other place the two APIs agree, differ for a reason, or differ
because nobody noticed.
Two crosspoint behaviours are deliberately not copied. It reinterprets a string in
argument 2 of a GET as a request body, which turns a mistyped headers table into a
silent protocol error. More seriously it calls setInsecure() on every request, so TLS
is encrypted but unauthenticated on the very path a firmware update would use; this
verifies against the root bundle already sitting in the framework, and the emulator
confirms expired.badssl.com is refused while a wrong sha256 deletes the file.
Downloading exposed two failures worth naming. A 2KB read buffer on the stack tripped
the loop task's canary because a TLS handshake had already spent it, and the
hand-rolled read loop spun forever on a stream that stopped producing -- HTTPClient's
own writeToStream handles both, so the loop is gone and the loop task gets 16KB.
scripts/gen_lua_stubs.py generates stubs/esp32lcd.lua in the same LuaLS format
crosspoint uses, reading annotations off the luaL_Reg tables so a module's docs sit
with its registration. make test runs --check, which crosspoint's copy never wired up.
The launcher clock read UTC and sat next to the title because the toolkit could only
stack children from the start of an axis. justify adds the CSS main-axis modes that
had a caller -- start, end, center, between -- so a header keeps its title left and
its clock right without any app doing arithmetic.
Timezones are stored as POSIX TZ rules rather than offsets, so newlib applies DST
changeovers and os.date() in Lua reports local time with no binding of its own.
/lib/timezones.lua is only the picker list: a zone missing from it still works if its
rule is written into settings, the same split themes already use.
settings_calibration.lua now finds rows by label. Adding the timezone row shifted
every hardcoded y coordinate in it, which is the failure that had been predicted and
would have silently retargeted taps at the wrong control.
Nothing in this firmware ever mounted internal flash storage: apps, lib, settings and
themes all live on the SD card. The Arduino default table still reserved 1408K for
spiffs, so a custom table hands that to the OTA slots instead, taking each from 1280K
to 1984K. The 64K tail the slots cannot use goes to coredump, which otherwise logs a
missing-partition error on every boot. Changing the table shifts app0, so the next
flash must be a full one.
The ESP32 has no battery-backed clock, so time is 1970 on every power-up. net.cpp
seeds it from the build timestamp -- the device cannot predate its own firmware, and
certificate validity checks need a plausible clock before SNTP can answer -- then
starts SNTP on each join and latches a synced flag from the notification callback.
sntp_get_sync_status() only reports COMPLETED briefly before resetting for the next
cycle, so polling it would make the clock flap.
sys.clockSynced() is the only new binding a clock UI needs; Lua's os.date() already
reads the same system clock. The launcher shows it, in UTC for now.
lua_app.cpp had grown to 701 lines holding every binding, the module loader and the
app lifecycle, so new bindings landed wherever the cursor was. Each Lua table now has
its own file under bindings/, and the app is recovered from the lua_State's extra
space instead of a file-static, so a second state cannot reach the wrong app.
Three tests each redeclared the binding surface, which broke twice this session when
a binding changed; test/fake_device.lua is now the single stub. `make test` runs all
four suites and pins Lua 5.4, matching the vendored interpreter rather than the 5.2
the tests had silently been using.
Corners were drawn by two disagreeing algorithms: a hand-rolled per-row inset for
the gradient fill and TFT_eSPI's Bresenham arc for the border, so they missed each
other by a pixel and left a halo. The inset was also wrong, truncating the sqrt and
ignoring pixel centres, which over-cut the top row by 2px into a visible chamfer.
gui.roundRect now derives fill and border from the same signed distance, blending
edge pixels by coverage, and replaces fillRoundRect, drawRoundRect and
fillRectGradient. The geometry moved to src/gfx/round_rect.h so round_rect_test.cpp
can check the arc on the host, since only the eye ever checked the old one.
Every app re-declared the same black/white/accent triple, so a palette now lives in
/lib/theme.lua and ui.lua derives muted, contrast, gradients and radius from it.
Deriving rather than listing means a new component costs no theme keys, and a theme
cannot pick text that is unreadable on its own accent.
Components take the palette by inheritance, so the three apps name no colors at all;
ui.theme stays for drawing outside the tree. Settings cycles the installed themes and
stores only the name.
Adds a wifi binding over the Arduino API and a settings flow that scans, picks
the strongest AP per SSID, takes a password from an on-screen keyboard, and
reports connection state. Credentials join /settings.lua and reconnect at boot.
Settings are now written through a temp file and rename, and strings are
Lua-escaped, so a password cannot corrupt the file the firmware parses at boot.
Apps describe nesting instead of coordinates: /lib/ui.lua borrows CSS block flow,
the box model and auto sizing, and owns hit testing, press capture and the pressed
repaint. The runtime gains require backed by the SD card, on_touch_down/on_touch_up,
text metrics and rounded gradient fills, so the launcher becomes an ordinary Lua app
and the firmware keeps only a fallback screen for an unreadable card.
Boots to a launcher that lists /apps/<name>/main.lua on the SD card and runs
the selected app in a vendored Lua 5.4 with gui, input, fs, sys and log
bindings. Settings persist as a Lua table in /settings.lua, covering touch
calibration and screen rotation, with a settings app to edit both. Rotation is
applied after mapping raw touch into the panel's rotation-0 frame, so turning
the UI never invalidates a calibration.