-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
Griffen Fargo edited this page May 15, 2026
·
2 revisions
Technical overview of how Lights Pi components fit together.
Browser / phone / voice ┌─────────── AI providers ──────────┐
│ │ OpenAI • Anthropic • Ollama │
│ WiFi / HTTPS └─────────────┬─────────────────────┘
▼ │
Raspberry Pi │
┌─────────────────────────────┐ │
│ nginx (80/443, optional) │ │
│ landing page + reverse proxy│ │
└──────────┬──────────────────┘ │
│ │
┌──────────▼──────────────┐ HTTP+WebSocket │
│ Flask control server │◄────────────────────┘
│ (port 5000) │
│ • AI chat → DMX │
│ • Live virtual console │
│ • Fixture groups │
│ • .qxf-aware channels │
│ • Scene save/snapshot │
└──────────┬──────────────┘
│ persistent WebSocket
▼
┌──────────────────────────┐
│ QLC+ headless (port 9999)│
│ + .qxf fixture defs │
└──────────┬───────────────┘
│ USB
▼
ENTTEC DMX USB Pro
│ DMX
▼
DMX Fixtures (rig)
| Service | Port | Purpose |
|---|---|---|
qlcplus-web.service |
9999 | QLC+ headless with web UI |
lighting-control.service |
5000 | Flask control server |
nginx |
80/443 | Landing page + optional HTTPS reverse proxy |
wifi-watchdog.timer |
— | Auto-recovery for dropped WiFi (every 2 min) |
The control server holds exactly one WebSocket to QLC+ for its entire lifetime. This is critical because QLC+ 4.14.x has a hard limit (~50) on concurrent WebSocket clients. Previous architectures that opened a new connection per request would exhaust this limit within minutes.
Key design decisions:
- Dedicated asyncio event loop in a daemon thread owns the WebSocket
- All Flask request handlers dispatch via
asyncio.run_coroutine_threadsafe - A background reader task continuously drains incoming messages
- On connection drop, the reader explicitly closes the socket (preventing CLOSE_WAIT leak) and the next request lazily reconnects
control-server/fixture_definitions.py reads .qxf files and resolves a semantic role for each channel:
-
<Channel Preset="IntensityRed">→ role =red -
<Colour>White</Colour>+ name contains "Warm" → role =warm - Exact channel name match ("Strobe" →
strobe) - Group classification (Shutter →
strobe, Colour →macro) - Channels in Speed/Maintenance/Effect groups → role =
null(never driven by color commands)
This metadata flows to:
- The AI prompt (so it picks correct channels per fixture)
- The UI (correct slider labels)
-
apply_color_live()(drives only color-role channels, zeros everything else)
- User sends AI command → scene XML generated and applied live
-
scene_xmlreturned in the API response - User clicks 💾 → frontend sends XML + name to
POST /api/scenes/save - Backend injects the
<Function>element into the workspace's<Engine> - Scene gets the next available ID and appears in the Scenes tab immediately
- Persists through reboots (it's in the
.qxwfile QLC+ loads on boot)
| Path | Purpose |
|---|---|
/home/<user>/.qlcplus/default.qxw |
Active workspace (loaded by QLC+ on boot) |
/home/<user>/.qlcplus/fixture_groups.json |
Persisted fixture groups |
/home/<user>/control-server/ |
Flask app source |
/home/<user>/control-server-venv/ |
Python virtual environment |
/usr/share/qlcplus/fixtures/ |
System fixture definitions (.qxf) |
~/.qlcplus/fixtures/ |
User fixture definition overrides |
/home/<user>/lightsctl.sh |
CLI entry point (deployed from workstation) |
/home/<user>/scripts/ |
Supporting scripts and libraries |