The pub’s 4 ADJ 18P HEX (6-in-1 RGBWA+UV) par can wash lights are driven by
the Raspberry Pi, replacing the standalone Hexcon HEX001 controller. A small Flask service on
the Pi (dmx-api, port 5001) holds the lighting state and continuously
transmits DMX-512; any control surface (Q-SYS, the admin, or curl) changes the
lights by calling its HTTP API.
The Pi is the sole DMX master on this line. DMX-512 allows exactly one controller per universe, so nothing else may drive the same XLR. Q-SYS / the admin control lighting by calling the API, never by touching the wire.
Code lives in pi/dmx-api/ in the pub repo. Service runs at /opt/dmx-api as
dmx-api.service; it auto-deploys on main via the existing
pi-capture-update.timer.
Signal chain
flowchart LR subgraph Surfaces["Control surfaces"] QSYS["Q-SYS / TSC-70<br/>(in-room, future)"] ADMIN["Admin UI / curl"] end QSYS -->|"HTTP POST :5001"| API ADMIN -->|"HTTP POST :5001"| API subgraph Pi["Raspberry Pi"] API["dmx_api.py<br/>Flask :5001"] -->|"updates in-memory frame"| TX["TX thread ~30 Hz"] TX -->|"pyserial 250000 8N2 + BREAK"| USB["/dev/ttyUSB0"] end USB --> CABLE["FTDI USB-DMX cable<br/>(DSD TECH SH-RS09B)"] CABLE -->|"DMX-512 (RS-485)"| F1["Par · pos 1 · addr 19"] F1 --> F2["Par · pos 2 · addr 13"] F2 --> F3["Par · pos 3 · addr 7"] F3 --> F4["Par · pos 4 · addr 1"]
The TX thread sends a continuous frame (BREAK → mark-after-break → start code 0x00 → 512 slots) at ~30 Hz, which is what holds the fixtures’ values. HTTP
handlers only mutate the in-memory frame; changes go out on the next refresh.
Hardware
The FTDI cable contains the RS-485 line driver. Bare Pi GPIO (single-ended 3.3 V) is not a valid DMX signal and will not light the fixtures — see Design decisions below.
Address order = physical order.
fixture_addresseslists the four addresses in the cans’ left-to-right order (software fixture N → physical position N). Here the cans turned out to be addressed in reverse of their position — the verified table is[19, 13, 7, 1]. Set it soscene/identifyreads red·green·blue·white across the room; then every positional effect (chase, wave, rainbow, police halves) flows correctly at once.
API reference
Base URL http://<pi>:5001. Every response includes serial/TX health
(serial_open, serial_error, tx_frames, active program).
Scene + program changes crossfade (eased), not hard-cut; the fade time is
configurable (fade_ms). Diagnostics (allon/probe) and manual per-fixture
tweaks are instant.
Static scenes: off, full, dim, red, green, blue, warm, party, identify (1=red 2=green 3=blue 4=white — first-light addressing + channel-order check).
Animated programs (rendered by the TX thread each frame):
chase, comet, wave, and music (vu/spectrum) take ?dir= forward /
reverse / center (bloom from the middle) / bounce (ping-pong) — so the
fill/motion isn’t fixed left-to-right (1→N).
Music modes (music program, ?mode=; drawn from club sound-to-light
practice — SoundSwitch beat-sync, Lightjams band analysis):
| mode | look |
|---|---|
vu | overall loudness fills fixtures 1→N, color cool→hot |
spectrum | each fixture = a frequency band (bass→high), scaled by loudness |
rgb | room color = spectral mix (bass→R, mid→G, high→B) |
beat | kick (bass transient) flashes all fixtures, fast decay |
energy | whole room breathes with energy; hue rotates |
strobe | strobe on loud passages, dark when quiet |
The live feed is pi-capture’s
level_meter: it samples the shared audio buffer, sends overall loudness + a
relative [bass, mid, high] spectrum (FFT) to /audio-level. Isolated from
song-ID (own thread, errors swallowed); gated by DMX_LEVEL_ENABLED. The
control page’s “demo beat” synthesizes a feed so every mode previews with no
audio attached.
Any static control (scene, blackout, per-fixture) stops a running program —
manual control always wins.
curl -X POST http://<pi-av-host>:5001/scene/full
curl -X POST http://<pi-av-host>:5001/program/rainbow
curl -X POST http://<pi-av-host>:5001/fixture/2/color \
-H 'Content-Type: application/json' -d '{"r":255,"g":0,"b":120,"dim":255}'
curl -X POST http://<pi-av-host>:5001/blackoutRuntime config and safety
POST /config live-edits settings without an SSH session; they persist to
/opt/dmx-api/runtime.json (excluded from the deploy rsync), so a setting
survives restarts and auto-deploys — set it once and it sticks. Each also has an
env default in /opt/dmx-api/.env and a control on the web Setup panel.
| Key | Env default | Effect |
|---|---|---|
enabled | DMX_OUTPUT_ENABLED | Hard stop. When off, the Pi transmits all-zeros no matter what’s clicked — a stray tap can’t light the room. Soft by default (Setup toggle re-arms). Setting DMX_OUTPUT_ENABLED explicitly in .env is a hard lock: authoritative over the persisted/UI state, and /config refuses to change it (409) — so DMX_OUTPUT_ENABLED=false forces blackout and no surface can override it |
intensity | — | Master brightness 0..1, scales the whole rig at the output stage (never touches the look) |
gamma | DMX_GAMMA | Output dimmer-response curve applied per channel at the TX stage (LED perceptual smoothing). 1.0 = linear (default, today’s exact behavior); >1 compresses the low end so fades read smoother (2.0 ≈ square), <1 brightens lows. Clamped 0.2–4.0, applied via a LUT (no per-frame pow()). Software-side so all four cans match without touching the fixture menus — but it interacts with the music-reactive auto-gain, so tune at the rig. output = int(gamma_lut[v] * intensity) |
fade_ms | DMX_FADE_MS | Crossfade time for scene/program changes (0–5000, default 500) |
hard_blackout | DMX_HARD_BLACKOUT | Force RGB→0 when a fixture’s dimmer is off (cheap pars bleed otherwise). On by default |
fixture_addresses | DMX_FIXTURE_ADDRESSES | The 4 fixtures’ DMX addresses, in physical order |
theme_sync | DMX_THEME_SYNC | Poll the bar’s theme accent for the theme program (see below) |
curl -X POST http://<pi-av-host>:5001/config \
-H 'Content-Type: application/json' -d '{"enabled":false}' # hard stopAutopilot (POST /autopilot/start?dwell=25) cycles a curated playlist
(aurora → warm → wave → rainbow → comet → party) on a dwell timer, riding the
crossfades. Any manual scene/program/blackout clears it — the operator always
wins.
Fixture discovery
When a fixture is dark or shows the wrong colors, it’s almost always an addressing mismatch, not signal. Find its real address from the Pi without touching it:
POST /allon— every channel full. If the dark fixture lights, it’s addressing (not signal/power).POST /probe/<base>— light only the fixture at DMX addressbase. For these 18P HEX cans (≥6 channels each), scan candidates 6 apart (1, 7, 13, 19, …) and watch which lights. Or sweepfixture_addressesand light one fixture’srchannel: the address where it shows Red is that can’s true start (ch1).- Put the four real addresses in
fixture_addresses(POST /config, Setup panel, or.env), thenscene/identifyto confirm red·green·blue·white across the room.
Scrambled colors are usually an address offset, not a channel-order problem. On a 6-in-1 fixture (ch1–6 = R,G,B,W,A,UV), writing 2 channels too high makes the controller’s R/G/B/W land on the can’s Blue/White/Amber/UV — so “red” shows blue, “green” shows white, etc. That’s what happened here: two cans were addressed at
7/19while the software wrote9/21. The fix was the address, notDMX_CHANNEL_ORDER. Symptom signature: a single consistent shift (R→B, G→W) across every preset. See the pub repo’spi/dmx-api/COLOR-TROUBLESHOOTING.mdfor the full 2026-06-23 diagnosis.
Theme-synced color
The theme program washes the rig in the bar’s current V2 theme accent
(navy / Pride / Knicks orange…), and eases to the new accent when the theme
changes. The Pi polls the worker for the color:
flowchart LR D1["theme_schedule / presets<br/>(D1)"] --> R["getActivePreset()"] R --> EP["GET /api/theme/current<br/>{accent, name}"] EP -->|"poll ~20 s (urllib)"| P["dmx-api theme poller"] P --> PR["'theme' program<br/>eases to accent"]
GET /api/theme/current is a patron-facing worker endpoint (edge-cached 30 s;
falls back to the default accent, never 500s). The poller is its own daemon
thread, gated by theme_sync, and self-heals — a failed fetch keeps the last
accent. The theme system is the same
one that drives the bar-TV palettes.
Operations
# bootstrap (one-time) — installs venv + service, retires olad
cd /opt/ihnyc-repo/pi/dmx-api && sudo ./deploy.sh
# health
curl -s localhost:5001/status | python3 -m json.tool # serial_open:true, tx_frames climbing
journalctl -u dmx-api -n 50Design decisions
| Decision | Why |
|---|---|
| FTDI USB-DMX cable, not bare GPIO | DMX is differential RS-485; the Pi’s UART TX is single-ended 3.3 V. Driving the line directly does not decode at the fixtures (proven live: the Hexcon lit them, bare GPIO didn’t). The USB cable has the RS-485 driver built in. |
| pyserial, not OLA | The Debian OLA 0.10.8 uartdmx plugin can’t set 250000 baud on the Pi (Failed to set baud rate to 250k). pyserial sets it via the kernel custom-divisor ioctl, and drives the FTDI cable identically. Dropping OLA also removed the olad daemon + its config fragility. |
| Pi as sole master; surfaces call HTTP | One controller per DMX line. Keeps the Pi the single device gateway, the same pattern it uses to broker AVPro/projector state over QRC. |
Control surfaces
Built: the engine + API, the control/preview page, the music-reactive feed
(pi-capture → /audio-level), crossfades, autopilot, master
brightness, the hard-stop safety switch, runtime config (persisted),
fixture discovery, and theme-synced color. Four surfaces drive it:
- Q-SYS plugin on the TSC-70 (
qsys/ihnyc-pub-lighting.qplug, v1.11 — installed and live). A native Q-SYS block (rendered by the Core, so the panel survives a Pi restart) that runs the HTTP +/statuspoll itself. Hexcon-parity control: Scenes, all animated Looks, the reactive Music modes, an 8-swatch color palette + R/G/B mix (→/color), Brightness / Speed / Fade faders, a latching Blackout hold (/config force_black), “My Looks” 6 save-a-look presets (/preset), and a “Moments” cue row (/cue: flash / last-call / birthday / closing). Live per-can color swatches mirror the rig. Status shows the active look, now-playing title, andOFF (locked)when hard-locked. Install = double-click the.qplug(it appears under the IHNYC category in Schematic Elements — not Asset Manager). Build guide:qsys/tsc-lighting-uci.md. - Admin lighting monitor (
/admin/lighting) — read-only web view of the rig state, fed by a D1desired/currentrelay the Pi polls (the worker can’t reach the NAT’d Pi directly, so it mirrors the sound-stage pattern). Control lives on the TSC +:5001; the worker monitors. - One-tap “Vibe” (
qsys/soundstage-vibe.lua) — a Core-side Control Script that sets a room mood in one press: recalls a sound-stage audio snapshot and drives the lighting together (Party / Chill / Movie / Closing). - On-Pi
:5001control page (http://<pi>:5001/) — the phone/bench surface, now at TSC live-control parity: Scenes + animated Looks + reactive Music modes, the “Dimmer curve” gamma slider, an 8-swatch “Color wash” + custom wash (/color), “My Looks” 6 save/recall preset slots (/preset), “Moments” cues (Flash / Last Call / Birthday / Closing,/cue), a latching “Blackout HOLD” banner (force_black) with a Release button, and Song color / Dark when silent toggles. Gamma + fixture-order stay:5001-only (bench tuning, not the in-room wall panel) — by design.
Not yet tuned (frameworks shipped, gated)
The autopilot “ambient” blend Look (theme × song color × live audio × a
serving-night arc), beat-grid tempo lock (DMX_BEATGRID_ENABLED), and
genre profiles (DMX_GENRE_PROFILES, house→pump / ambient→wash) ship inert —
detection works; the feel needs tuning at the rig with eyes on the cans.
Related
- index — pub service overview
- now-playing-system — the same Pi runs song-ID
- ops — deployment + operations