-
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 MCP-aware LLM agents ┌─────────── AI providers ──────────┐
│ (Claude Desktop, etc.) │ OpenAI • Anthropic • Ollama │
│ WiFi / HTTPS │ └─────────────┬─────────────────────┘
▼ ▼ │
Raspberry Pi │
┌─────────────────────────────┐ ┌──────────────────────────┐ │
│ nginx (80/443, optional) │ │ MCP server (port 5001) │ │
│ landing page + reverse proxy│ │ Streamable HTTP @ /mcp │ │
└──────────┬──────────────────┘ └───────────┬──────────────┘ │
│ │ HTTP │
┌──────────▼─────────────────────────────────▼──┐ HTTP+WebSocket │
│ Flask control server (port 5000) │◄───────────────┘
│ • AI chat → DMX │
│ • Live virtual console │
│ • Fixture groups │
│ • .qxf-aware channels │
│ • Scene save/snapshot │
│ • /api/action structured dispatch (for MCP) │
└──────────┬────────────────────────────────────┘
│ 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 |
lighting-mcp.service |
5001 | MCP server (Streamable HTTP, LLM agent endpoint) — see MCP-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 (control server) |
/home/<user>/mcp-server/ |
MCP server source |
/home/<user>/mcp-server-venv/ |
Python virtual environment (MCP server) |
/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 |