The control layer for the LightMap Monaco installation: mode system, transitions, operator panel, event scheduling, offline-first state. Built to run unattended on a Pi 5 after the installation is sold.
The original lightmap.py was a REPL — you typed commands, modes ran their own
blocking loops. That doesn't survive being sold and walked away from. This is the
persistent-installation version: a render loop, four (well, three) interchangeable
modes, live mode-switching with crossfades, a boot sequence, a phone operator
panel, and an event scheduler that's offline-tolerant.
| Mode | Role | Notes |
|---|---|---|
| ambient | the hero — runs ~95% of the time | day/night colour-temp cycle + a texture layer (wave or twinkle — both implemented, A/B on hardware) |
| data | the civic upsell mode | building height now; layer hook for OSM roads/density/elevation later |
| event | the closer — operator-triggered | radial ripple (yachts etc.) or sweep along a path (the GP circuit) |
All three are deliberately generic. That's the sales strategy: a buyer watching
data thinks "I want my data", a buyer watching event thinks "I want my
events". Tailoring them now gives away the supplement. Generic ≠ unfinished though
— ambient especially is meant to be genuinely polished.
A 4th purely-aesthetic mode slot is reserved in lm_modes.py (the standalone
signature ripple got absorbed into event's radial style). Subclass Mode,
register it in build_default_modes(), nothing else changes.
runtime.py (one process on the Pi)
|
+--------------+--------+--------+----------------+
| | | |
ModeManager SchedulerThread SyncThread Flask operator app
(main thread) (daemon) (daemon) (daemon)
render loop reads refreshes phone panel on the LAN
@ 30fps events.json events.json four buttons + slider
-> drives from backend
event mode (offline-OK)
| | | |
+--------------+--------+--------+----------------+
|
StateStore (state.json + events.json)
the Pi's LOCAL source of truth
One rule that drives the whole design: nothing in the render path or the
scheduler ever does network I/O. The backend is a sync source, never a command
source. Backend down — venue wifi drops, VPS dies, domain lapses in 2027 — is a
non-event. The Pi runs forever off its local events.json cache.
One mechanism for mode changes: the operator panel, the scheduler, and the
boot default all call manager.request_mode(). No privileged path. A human tap
and a scheduler nudge are the same kind of call; last write wins, so the operator
can always override an automated trigger.
- Crossfade (
ModeManager.CROSSFADE_S, 0.7s) — mode-to-mode. A hard buffer swap reads as a glitch on RGBW strips. The manager renders both modes into scratch buffers and alpha-blends, ease-in-out. - Boot sweep (
ModeManager.BOOT_S, 3.2s) — on power-up, a band of light sweeps west→east across the map and settles into the active mode. Theatre (the map arrives) and diagnostics: the sweep touches every pixel, so if strip 10 or 13 misbehaves again, the boot wash shows it before any visitor is there.
| File | What | Status |
|---|---|---|
runtime.py |
production entry point — wires everything | real |
lm_modes.py |
Mode base + 3 modes |
real |
mode_manager.py |
render loop, crossfade, boot | real |
state_store.py |
state.json + events.json, atomic writes |
real |
threads.py |
SyncThread (stub) + SchedulerThread |
scheduler real, sync stubbed |
operator_app.py |
Flask operator panel | real |
geo_utils.py |
shared spatial math, polyline resampling | real |
circuit_monaco.geojson |
the F1 circuit polyline | real |
dev_geometry.py |
synthetic geometry generator | DEV ONLY — delete when real mapping lands |
test_harness.py |
headless full-stack test | DEV ONLY |
geometry.py, framebuffer.py, transport.py |
your originals, untouched | real |
modes.py, lightmap.py |
your originals — REPL debug fallback | kept, not used by runtime |
# dev — fake serial, synthetic geometry, no hardware needed
python3 runtime.py --dev
# on the Pi — auto-detect Scorpio boards
python3 runtime.py
# explicit ports
python3 runtime.py /dev/ttyACM0 /dev/ttyACM1
# full headless test (22 checks)
python3 test_harness.pyOperator panel: http://<pi-ip>:8080/
Needs flask and pyserial (pyserial only for real hardware — --dev fakes it).
The backend doesn't exist yet — and shouldn't be built first. It's a standard CRUD-over-an-event-table with a calendar UI; zero dependency on the mapping or hardware; the least risky thing in the project. Build it from home, second, so the data contract is already proven by the thing consuming it.
SyncThread._fetch_remote() is the only thing that changes when the backend is
real — swap the stub for an HTTP GET. The contract is locked: the backend
must return JSON matching events.json's shape:
{
"version": 1,
"events": [
{
"name": "Monaco Grand Prix",
"geometry": {"type": "path", "coords": [[lat,lng], ...]},
"style": "sweep",
"color": [200, 0, 0, 0],
"radius_m": 70,
"speed_mps": 90,
"active": false,
"window": {"start": "ISO8601", "end": "ISO8601"}
}
]
}geometry.type is point or path; style is radial or sweep. A point +
radial is a yacht ripple; a path + sweep is the GP. Same schema, same EventMode.
The window field is where datetime-based auto-activation goes — right now
active is a manual flag (operator panel / set_event_active()). When the
scheduler computes active from window start/end, that's a change inside
SchedulerThread._tick() only.
- Drop the real
geometry.json+buildings.csvin. Deletedev_geometry.py. - The mode code depends only on the interface (
geo.all_pixelswith.lat/.lng,geo.buildingswith.height,geo.channels, the pixel→channel mapping) — all of whichgeometry.pyalready provides. So the modes shouldn't need changes. If the real geometry has very different pixel density, re-check ambient's per-frame cost (see Performance). - Re-run
test_harness.pyagainst the real geometry to confirm.
Profiled on the real 804-pixel geometry. All modes well under the 33ms/frame budget, with massive headroom — even on a Pi 5 (~3-5x slower for pure-Python loops):
| mode | ms/frame (dev) | est. Pi 5 | headroom |
|---|---|---|---|
| ambient | 2.3ms | ~7-10ms | comfortable |
| data | 0.1ms | <1ms | trivial |
| event (sweep + real circuit) | 0.2ms | <1ms | trivial |
You could enable Gaussian blur in production without breaking 30fps. (My earlier "ambient is the expensive mode, watch for frame drops" note was based on a 2016-px synthetic stand-in — disregard it.)
The _pixel_path_dist precompute in EventMode._build_geometry() is the only
notable cost — O(pixels × path-points) per set_event() call. Runs off the render
thread (scheduler calls it), so the display doesn't stutter, but the crossfade into
event mode may visibly lag the trigger by a fraction of a second. If that bugs you
on hardware, cache _pixel_path_dist keyed by event name so re-triggering the GP
is instant. Not done yet — premature until you see it.
(From running against the real geometry.json: 21 strips, 804 pixels.)
GP sweep coverage: only 63 of 804 pixels are within 50m of the circuit, 108
within 100m. With the default radius_m=90, the sweep only lights ~13% of the map
— a precise head tracing the track through a mostly-dark map. That may or may not
be the look you want; bump radius_m to 200-300 on the GP event for a wider, more
theatrical glow. Two paths:
- Surgical (current default): small bright head, precise circuit tracing, most of map dark. Reads as data/information.
- Theatrical (bump to 200-300m): wider glow following the car. Track shape blurs but the mode lands harder visually.
I'd lean theatrical for the demo (event mode is the closer, needs to land); keep surgical as a custom-mode upsell for an F1-team buyer.
Data mode coverage: the map physically can't show every tall building — strips don't cover everywhere. Expect roughly 60-70% of tall buildings to map to a pixel within 300m. Data mode shows a constellation of where tall buildings cluster, not a one-to-one rendering. Not a bug, but know it.
Sanity check before the next workshop visit: the boot sweep west->east
assumes geometry coordinates correspond to physical position. If a strip's
pixels are misaligned in geometry.json, the boot wash will look wrong on that
strip — a free diagnostic the first time you power it up.
framebuffer.py flush() has its docstring inside the with self._lock:
block — it's a no-op string statement, not a docstring. Harmless, didn't touch
your file, but worth a one-line fix when you're next in there.