/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/lighting is 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 :5001 dmx-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
  1. Admin sets desired. The operator posts an intent to POST /api/admin/dmx/desired; the worker writes it to pub_dmx_state.desired_json with a desired_set_at timestamp.
  2. Pi polls. The Pi’s reconcile loop (pi_capture, gated DMX_CONTROL_ENABLED) hits GET /api/dmx/desired every ~5s and reads the pending intent.
  3. Pi applies. It translates the intent to the local dmx-api :5001 endpoints (scene/<name>, program/<name>, blackout, config, …) — no dmx-api change was needed.
  4. Pi reports current. Its heartbeat carries a dmx block — the live rig state it read from :5001’s /status — plus applied_set_at echoing back the desired_set_at it just applied.
  5. Worker clears on reach. The heartbeat handler writes current_* and, when applied_set_at matches the stored desired_set_at, clears the desired cell. The nudge is now spent.
  6. Admin reads. /admin/lighting polls /api/admin/api-health every 5s and renders the dmx block.

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).

ColumnMeaning
desired_jsonThe 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_atWhen the admin set it — the key the clear-on-reach matches against.
current_jsonFull live rig snapshot the Pi forwarded (look, serial_open, output_locked, autopilot, song, intensity, desired_fetch_ok, …).
current_serial_ok0 = the USB-DMX cable wasn’t open on the Pi’s last read (the rig physically can’t light).
current_lookThe active look name (flat column the admin reads directly).
current_locked1 = DMX_OUTPUT_ENABLED is hard-locked off on the Pi → the admin shows OFF (locked) and presses no-op.
current_seen_atWhen 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

EndpointAuthPurpose
POST /api/admin/dmx/desiredVerified Pub operator with tv.dmxSet the desired intent. Body {action, name?, params?}. Writes desired_json + desired_set_at.
GET /api/dmx/desiredPi 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-healthAdminReturns a dmx block (desired, current, look, serialOk, locked, currentSeenAt) the page reads.
/admin/lightingAdmin (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-api is a separate process that survives a pi_capture restart, so a pi_capture restart doesn’t lose the look. Only a dmx-api restart loses it, and persisting the standing look across that belongs in the dmx-api’s own runtime.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=false on the Pi; the room is dark and no surface can override it.
  • Output off — output disabled (soft); the room is dark.
  • Control plane downdesired_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 openserial_open false: 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 a Blackout / Idle read 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 .env

Until 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.


  • 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