evan eec9b692c6 feat(wifi): scan, connect and persist networks from the settings app
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.
2026-08-01 06:29:38 -04:00

esp32-lcd

Lua-app firmware for the 4.0" ESP32-32E display (lcdwiki E32R40T): ST7796S 320x480 SPI panel + XPT2046 resistive touch + microSD. Apps are plain Lua files on the SD card, launched from an on-screen menu. Inspired by crosspoint-reader's plugin system.

Build & flash

nix develop          # provides pio
pio run              # build
pio run -t upload    # flash over USB-C
pio device monitor   # serial logs

Copy sdcard/ to the SD card root: apps live in /apps/<name>/main.lua and shared Lua modules in /lib. The launcher is itself an app (/apps/launcher/main.lua); the firmware only draws a fallback screen if it cannot start.

Lua API

Apps define optional callbacks: setup(), 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 setting TICK_MS). Call sys.exit() to return to the launcher.

require reads from the SD card: /apps/<name>/?.lua first, then /lib/?.lua.

Module Functions
gui width(), height(), clear(color), fillRect(x,y,w,h,c), drawRect(x,y,w,h,c), fillCircle(x,y,r,c), drawLine(x1,y1,x2,y2,c), drawText(text,x,y,fg,bg), fillRoundRect(x,y,w,h,r,c), drawRoundRect(x,y,w,h,r,c), fillRectGradient(x,y,w,h,r,top,bottom), fontHeight(), textWidth(text), setRotation(0-3), color(r,g,b)
input getTouch() -> x,y or nil, getRawTouch() -> raw ADC x,y or nil, touched()
fs readFile(path), writeFile(path, data), exists(path), listFiles(path), listDirs(path)
sys millis(), exit(), launch(path), setCalibration(x0,y0,x1,y1), getRotation(), setRotation(deg)
wifi scan() -> {ssid,rssi,secure}[], connect(ssid,password), status() -> {state,ssid,ip,rssi}, forget()
log info(msg) (serial)

Colors are RGB565 integers; build them with gui.color(r, g, b).

Settings

/settings.lua on the SD card is a Lua table literal, read at boot and rewritten by the settings app. It is Lua rather than JSON because the firmware already links Lua, so it needs no parser on either side:

return {
  rotation = 90,
  touch = { x0 = 188, y0 = 232, x1 = 3799, y1 = 3800 },
  wifi = { ssid = "network", password = "secret" },
}

Missing file means the built-in defaults are used. apps/settings walks two crosshairs and saves the result via sys.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.

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 sys.setRotation() is safe at any time. gui.setRotation(0-3) changes only the current frame; the launcher restores the saved rotation when an app exits. The glass itself is always portrait, so a rotated UI is drawn sideways on it.

nix run nixpkgs#lua -- test/ui_layout.lua              # layout, hit testing, capture
nix run nixpkgs#lua -- test/settings_calibration.lua   # calibration math and menu flow

UI toolkit (/lib/ui.lua)

Apps describe nesting and sizes fall out, borrowing CSS block flow and the box model without the cascade:

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},
})

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

Sizes are pixels (>= 1), a fraction of the parent's content box (< 1), or "auto" (the default on the flow axis; the cross axis fills the parent). Boxes take pad, gap, row, align and at for absolute placement. color, bg, radius, gradient and the press palette inherit from the root, so a theme is set once.

The toolkit owns press capture (release outside cancels), an 80 ms minimum pressed duration, touch slop, and per-component dirty tracking. Any table with measure, place, draw and hit drops into the tree, so custom components need no buy-in.

Not implemented: scrolling, so a list longer than the screen is unreachable.

Pin map (fixed by the board)

  • Display (VSPI): SCK 14, MOSI 13, MISO 12, CS 15, DC 2, backlight 27, reset = EN
  • Touch: shares display SPI, CS 33, IRQ 36
  • SD: SCK 18, MOSI 23, MISO 19, CS 5

TFT_eSPI, XPT2046 and SD each grab the SPI controller but share the same wires; only one transfers at a time, which is safe here (see ponytail note in main.cpp).

S
Description
No description provided
Readme 1,013 KiB
Languages
C++ 51%
Lua 45.5%
Makefile 1.4%
C 1.4%
Nix 0.7%