/admin/lighting is the web admin’s read-first operator console for the pub’s
DMX par-can rig. It is a different surface from the DMX lighting hardware
doc (the fixtures, the Pi’s dmx-api Flask service, the FTDI cable, the channel
map) — read that one for how the lights physically work. This page is about the
worker side: the /admin/lighting monitor and the declarative desired/current
relay that connects the cloud worker to the LAN-bound Pi.
The split of duties:
- The TSC-70-G3 touchscreen (the Q-SYS plugin) stays the targeted in-room control surface — staff standing at the bar drive the lights from there.
/admin/lightingis the remote monitor — a web operator (anywhere, behind Cloudflare Access) sees the live rig state and can post a nudge: a desired scene / program / blackout that the Pi reconciles to.
Control of record lives on the TSC-70 and the Pi’s
:5001dmx-api. The admin console monitors the rig and can request a look; it never owns the rig’s state.
Why a relay (and not a direct call)
The worker runs at Cloudflare’s edge. The Pi sits behind the pub’s NAT on the LAN
— the worker cannot reach the Pi’s :5001 API directly. So control can’t be a
synchronous HTTP call from the worker to the Pi. Instead the worker and the Pi
share a tiny mailbox in D1, and the Pi polls the worker — exactly the pattern
the Q-SYS sound-stage relay
uses for audio modes.
Declarative, not a command queue
The first design (an office-hours pick) was a command queue — a list of
imperative actions the Pi drains. That was reversed in eng review in favour of
a declarative desired/current model (decision df844c6f): a single D1 row that
mirrors one desired intent and one reported current state, structurally
identical to pub_qsys_state.
The reason is convergence. The rig has two other masters that move it on their
own: the in-room TSC-70 and the dmx-api’s own autonomous engine (autopilot, the
per-song theme hue). A queue of imperatives would perpetually fight them — every
queued command re-fires whether or not the operator still wants it. A desired
state is a nudge that clears itself once reached (see clear-on-reach below),
so a standing admin intent stops asserting the moment the rig gets there, and the
TSC or the engine is free to take over. The admin’s desired is a nudge, not a pin.
The relay, end to end
flowchart LR ADMIN["/admin/lighting<br/>web operator"] subgraph CF["Cloudflare Worker + D1"] EP1["POST /api/admin/dmx/desired"] ROW["pub_dmx_state (1 row)<br/>desired_json · current_*"] EP2["GET /api/dmx/desired"] HB["heartbeat handler<br/>(clear-on-reach)"] HEALTH["GET /api/admin/api-health<br/>{dmx} block"] end subgraph Pi["Raspberry Pi (LAN / NAT'd)"] REC["reconcile poll ~5s"] API["dmx-api :5001"] end ADMIN -->|"set desired intent"| EP1 --> ROW ROW --> EP2 EP2 -->|"Pi polls ~5s (Bearer)"| REC REC -->|"applies via local HTTP"| API API -->|"live /status"| REC REC -->|"heartbeat {dmx, applied_set_at}"| HB --> ROW ROW --> HEALTH HEALTH -->|"poll every 5s"| ADMIN
- Admin sets desired. The operator posts an intent to
POST /api/admin/dmx/desired; the worker writes it topub_dmx_state.desired_jsonwith adesired_set_attimestamp. - Pi polls. The Pi’s reconcile loop (
pi_capture, gatedDMX_CONTROL_ENABLED) hitsGET /api/dmx/desiredevery ~5s and reads the pending intent. - Pi applies. It translates the intent to the local
dmx-api:5001endpoints (scene/<name>,program/<name>,blackout,config, …) — nodmx-apichange was needed. - Pi reports current. Its heartbeat carries a
dmxblock — the live rig state it read from:5001’s/status— plusapplied_set_atechoing back thedesired_set_atit just applied. - Worker clears on reach. The heartbeat handler writes
current_*and, whenapplied_set_atmatches the storeddesired_set_at, clears the desired cell. The nudge is now spent. - Admin reads.
/admin/lightingpolls/api/admin/api-healthevery 5s and renders thedmxblock.
The D1 mirror — pub_dmx_state
Migration 0041_pub_dmx_state.sql (on SETLIST_DB) creates a single-row table
(CHECK (id = 1)), seeded with INSERT OR IGNORE. It is structurally a twin of
pub_qsys_state (migration 0028).
| Column | Meaning |
|---|---|
desired_json | The admin’s pending intent: one action (scene / program / program_stop / autopilot / blackout / config) + an optional name + a params object. The Pi parses it. |
desired_set_at | When the admin set it — the key the clear-on-reach matches against. |
current_json | Full live rig snapshot the Pi forwarded (look, serial_open, output_locked, autopilot, song, intensity, desired_fetch_ok, …). |
current_serial_ok | 0 = the USB-DMX cable wasn’t open on the Pi’s last read (the rig physically can’t light). |
current_look | The active look name (flat column the admin reads directly). |
current_locked | 1 = DMX_OUTPUT_ENABLED is hard-locked off on the Pi → the admin shows OFF (locked) and presses no-op. |
current_seen_at | When the Pi last reported. |
Every column is nullable/defaulted, so reads tolerate a pre-0041 DB — the
heartbeat and the admin both degrade to “no lighting data” rather than 500. The
admin page distinguishes the two empty states by name: dmx === null means the
table is missing (migration not applied), dmx.current === null means the table
exists but no heartbeat has landed yet.
Worker endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
POST /api/admin/dmx/desired | Verified Pub operator with tv.dmx | Set the desired intent. Body {action, name?, params?}. Writes desired_json + desired_set_at. |
GET /api/dmx/desired | Pi Bearer token (NOW_PLAYING_INGEST_TOKEN, same as /api/qsys/desired + the ingest) | Pi’s fast poll. Returns {desired, desiredSetAt} or nulls. |
heartbeat dmx block | (the ingest path) | Pi reports current_*; clears desired on reach. |
GET /api/admin/api-health | Admin | Returns a dmx block (desired, current, look, serialOk, locked, currentSeenAt) the page reads. |
/admin/lighting | Admin (page load) | The console HTML; polls api-health every 5s. |
Pass-through validation (the key design call)
POST /api/admin/dmx/desired validates only the stable action enum
(DMX_ACTIONS = ["scene", "program", "program_stop", "autopilot", "blackout", "config"]) and that params is a plain object. It deliberately does not know
the scene / program / music-mode names — those live in the Pi’s dmx-api, and a
new engine look should ship with zero worker changes. So a program named
doesnotexist is accepted at the worker and forwarded; the Pi is the single
source of truth for valid names and surfaces an unknown one as desired_error in
its heartbeat. Keep the enum in sync with the dmx-api’s endpoint shape, not its
look catalog.
The intent is stored as one opaque JSON cell capped at 2 KB (truncateString), so
a malformed params can’t bloat the row.
How the Pi reconciles
The Pi side lives in pi_capture (the same process that does song-ID and the
heartbeat), gated by DMX_CONTROL_ENABLED, with pi_capture/dmx.py holding the
intent→endpoint translation. A ~5s reconcile poll reads GET /api/dmx/desired,
maps the intent to the local dmx-api :5001 calls, and the heartbeat forwards
the live rig state plus applied_set_at. There is no dmx-api change — the
relay rides on top of the existing Flask API.
Boot re-assert is deferred. The
dmx-apiis a separate process that survives api_capturerestart, so api_capturerestart doesn’t lose the look. Only admx-apirestart loses it, and persisting the standing look across that belongs in thedmx-api’s ownruntime.json, not in this relay.
What the console shows
The page renders the live rig from the dmx block, refreshing every 5s, with a
status dot + headline that names the operator’s situation precisely:
- OFF (locked) —
DMX_OUTPUT_ENABLED=falseon the Pi; the room is dark and no surface can override it. - Output off — output disabled (soft); the room is dark.
- Control plane down —
desired_fetch_ok === false: the Pi isn’t reading commands (auth/poll failing). The in-room surfaces still work; only this remote monitor may lag. - USB-DMX cable not open —
serial_openfalse: the rig can’t light regardless of the look. - Otherwise the active look (+
· autopilot, the now-playing♪ song, brightness %, “output locked on”), with a live per-fixture color-swatch strip and aBlackout/Idleread when the rig computes dark.
The desired_fetch_ok field drives a “control plane” badge: Healthy (true),
Down (false), or Control off (null — the normal state, since control lives on
the TSC / :5001, not here).
Go-live
# apply the migration to remote prod
wrangler d1 execute SETLIST_DB --remote --file=./migrations/0041_pub_dmx_state.sql
# then enable the Pi reconcile loop
# set DMX_CONTROL_ENABLED=true in the Pi .envUntil both are done the relay is dormant: the admin shows “Lighting not set up yet” (no table) or “Waiting for the Pi” (no heartbeat), and never errors.
Related
- dmx-lighting — the par-can hardware, the
dmx-api, scenes/programs, fixture addressing (the rig itself) - qsys-control — the sound-stage relay this one mirrors
- index — pub service overview