Documentation
Architecture
3 min readEdit on GitHub
Technical overview of how Lights Pi components fit together.
System Diagram
snippet
1 Browser / phone / voice MCP-aware LLM agents ┌─────────── AI providers ──────────┐
2 │ (Claude Desktop, etc.) │ OpenAI • Anthropic • Ollama │
3 │ WiFi / HTTPS │ └─────────────┬─────────────────────┘
4 ▼ ▼ │
5 Raspberry Pi │
6 ┌─────────────────────────────┐ ┌──────────────────────────┐ │
7 │ nginx (80/443, optional) │ │ MCP server (port 5001) │ │
8 │ landing page + reverse proxy│ │ Streamable HTTP @ /mcp │ │
9 └──────────┬──────────────────┘ └───────────┬──────────────┘ │
10 │ │ HTTP │
11 ┌──────────▼─────────────────────────────────▼──┐ HTTP+WebSocket │
12 │ Flask control server (port 5000) │◄───────────────┘
13 │ • AI chat → DMX │
14 │ • Live virtual console │
15 │ • Fixture groups │
16 │ • .qxf-aware channels │
17 │ • Scene save/snapshot │
18 │ • /api/action structured dispatch (for MCP) │
19 └──────────┬────────────────────────────────────┘
20 │ persistent WebSocket
21 ▼
22 ┌──────────────────────────┐
23 │ QLC+ headless (port 9999)│
24 │ + .qxf fixture defs │
25 └──────────┬───────────────┘
26 │ USB
27 ▼
28 ENTTEC DMX USB Pro
29 │ DMX
30 ▼
31 DMX Fixtures (rig)Services on the Pi
| 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) |
Persistent WebSocket
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
Fixture Definition Parser
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)
Scene Save Flow
- 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)
File Locations on Pi
| 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 |
Was this page helpful?