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

PartDetail
InterfaceFTDI “Open DMX USB” cable — DSD TECH SH-RS09B, at /dev/ttyUSB0
Fixtures4 × ADJ 18P HEX — 18× 12 W 6-in-1 (RGBWA+UV) LEDs, daisy-chained, ~15 m run
Fixture channel mapch1–6 = R, G, B, W, A, UV — identical in every 18P HEX mode (6/7/8/12-CH). dmx_api writes only the first 4: R, G, B, and the dim slot → White (ch4); Amber + UV stay 0
DMX addresses[19, 13, 7, 1] (verified 2026-06-23) — cans are addressed in reverse of physical order and 6 apart (each HEX occupies ≥6 channels). Set with fixture_addresses (POST /config) or DMX_FIXTURE_ADDRESSES. Match the cans’ real addresses — discover from the Pi, no climbing (see )
Channel orderR, G, B, Dim — correct for these cans; leave DMX_CHANNEL_ORDER alone
CouplerA 3-pin female-to-female XLR may be needed (chain end + cable XLR can both be male)

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_addresses lists 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 so scene/identify reads 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).

Method & pathPurpose
GET /statusChannel values + serial/TX health + live config (enabled, intensity, theme accent…)
GET /fixtures · GET /scenes · GET /programsCurrent fixtures / available names
GET /config · POST /configRead / live-edit runtime config (see )
POST /blackoutFade all channels → 0
POST /scene/<name>Static scene (see below)
POST /fixture/<n>/channel/<c>/level/<0-255>One channel (c = r/g/b/dim) on fixture n (1–4)
POST /fixture/<n>/colorJSON {r,g,b,dim} (any subset) on fixture n
POST /colorWash the whole rig to one solid color (?r=&g=&b=&dim= or JSON) — the color-macro / manual-mix path
POST /preset/save/<1-6> · POST /preset/recall/<1-6> · GET /presetsSave-a-look slots: capture the live look (program or static frame) + recall it; persisted in the runtime config
POST /cue/<name>Moment cue (?secs=): flash, lastcall, birthday, closing — a timed overlay that takes over then restores the prior look
GET /Control + live-preview web page (open http://<pi>:5001/)
POST /program/<name>Start an animated program (?speed=&r=&g=&b=, +&dir=, +&mode=)
POST /program/stopStop animating; freeze on the last frame
POST /autopilot/start · POST /autopilot/stopHands-off playlist cycler (?dwell= seconds)
POST /allon · POST /probe/<base>Diagnostics for
POST /audio-levelFeed the music program: {level:0..1, bands:[bass,mid,high]}

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

ProgramEffect
rainbowHue spread across fixtures, sweeping
fadeAll fixtures one color, slow hue cycle
chaseA lit point stepping along the line
cometChase with a soft fading tail (sharper head)
waveBrightness bump travelling across fixtures
auroraDreamy green/blue/violet hue + brightness drift
themeWashes the rig in the bar’s current V2 theme accent (see )
theaterTheater chase (alternating fixtures)
pulseBrightness breathe on a color
strobeAll flash on/off
candleWarm organic flicker
policeRed/blue alternating flash across the two halves
sparkleRandom twinkle
musicSound-reactive (see modes below)

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

modelook
vuoverall loudness fills fixtures 1→N, color cool→hot
spectrumeach fixture = a frequency band (bass→high), scaled by loudness
rgbroom color = spectral mix (bass→R, mid→G, high→B)
beatkick (bass transient) flashes all fixtures, fast decay
energywhole room breathes with energy; hue rotates
strobestrobe 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/blackout

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

KeyEnv defaultEffect
enabledDMX_OUTPUT_ENABLEDHard 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
intensityMaster brightness 0..1, scales the whole rig at the output stage (never touches the look)
gammaDMX_GAMMAOutput 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_msDMX_FADE_MSCrossfade time for scene/program changes (0–5000, default 500)
hard_blackoutDMX_HARD_BLACKOUTForce RGB→0 when a fixture’s dimmer is off (cheap pars bleed otherwise). On by default
fixture_addressesDMX_FIXTURE_ADDRESSESThe 4 fixtures’ DMX addresses, in physical order
theme_syncDMX_THEME_SYNCPoll 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 stop

Autopilot (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:

  1. POST /allon — every channel full. If the dark fixture lights, it’s addressing (not signal/power).
  2. POST /probe/<base> — light only the fixture at DMX address base. For these 18P HEX cans (≥6 channels each), scan candidates 6 apart (1, 7, 13, 19, …) and watch which lights. Or sweep fixture_addresses and light one fixture’s r channel: the address where it shows Red is that can’s true start (ch1).
  3. Put the four real addresses in fixture_addresses (POST /config, Setup panel, or .env), then scene/identify to 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/19 while the software wrote 9/21. The fix was the address, not DMX_CHANNEL_ORDER. Symptom signature: a single consistent shift (R→B, G→W) across every preset. See the pub repo’s pi/dmx-api/COLOR-TROUBLESHOOTING.md for 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 50
SymptomCause / fix
serial_open: falseCable unplugged (ls /dev/ttyUSB*, `dmesg
tx_frames not climbingTX thread can’t write — check journalctl -u dmx-api
Nothing lights, tx_frames climbing, status shows OUTPUT OFFThe master hard stop is on — set enabled back on (Setup toggle or POST /config {"enabled":true})
Nothing lights, tx_frames climbingFixtures unpowered / XLR gender mismatch / addresses not set / not in DMX-control mode
One fixture dark, others fineAddressing — run and fix fixture_addresses
Colors scrambled on one or two cans (R→blue, G→white — a consistent shift)An address offset, not channel order: fixture_addresses writes mid-fixture on a 6-in-1 can. Find the real start (where its r shows Red) and fix fixture_addresses — see
Colors wrong on all cans (red shows green)Genuine fixture channel order ≠ R,G,B,Dim — set DMX_CHANNEL_ORDER in /opt/dmx-api/.env (e.g. dim,r,g,b) + restart
Effects pair the wrong cans (e.g. police 1+4 / 2+3)fixture_addresses isn’t in physical order — reorder until scene/identify reads red·green·blue·white left-to-right
FlickerAdd a 120 Ω terminator across Data+/Data− at the last fixture

Design decisions

DecisionWhy
FTDI USB-DMX cable, not bare GPIODMX 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 OLAThe 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 HTTPOne 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:

  1. 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 + /status poll 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, and OFF (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.
  2. Admin lighting monitor (/admin/lighting) — read-only web view of the rig state, fed by a D1 desired/current relay 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.
  3. 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).
  4. On-Pi :5001 control 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.