External API Reference¶
The controller exposes two external interfaces on port 80:
- WebSocket at
ws://lightnet-<chipid>.local/ws— binary, low-latency, for real-time panel control and reactive triggers - HTTP at
http://lightnet-<chipid>.local— JSON, for discovery, appearance control, scene management, and firmware updates
The controller is discoverable via mDNS as lightnet-<chipid>.local with service _lightnet._tcp.
Choosing the right interface
Use HTTP for setup, configuration, and state queries. Use WebSocket for interactive control and reactive beat triggers — it cuts round-trip latency from ~5 ms to sub-millisecond.
Table of Contents¶
- WebSocket Binary API
- Packet structure
- Commands — TOGGLE (1), SET_COLOR (3), GET_PANELS_STATES (5), GET_EDGES_LIST (4), ANIMATION_TRIGGER (8), SET_MIRROR (10), PING (11)
- Responses — PANELS_STATES (6), EDGES_LIST (7), MIRROR_BATCH (9), PONG (12), APP_STATE (13)
- Sending a command — worked example
- HTTP API
- Appearance
- Palettes
- Scenes
- Animations
- Firmware updates
- Panels
- Configuration
- State
- MQTT (Home Assistant)
- Error handling
1. WebSocket Binary API¶
Connect to ws://lightnet-<chipid>.local/ws. All frames are binary. The protocol is defined in lib/Lightnet/Controller/API/websocket/WebsocketApi.hpp (namespace WebsocketApi).
1.1 Packet structure¶
Every frame has a fixed 14-byte header (PacketMeta) followed by a variable payload.
| Offset | Size | Field | Type | Description |
|---|---|---|---|---|
| 0 | 1 B | type |
uint8 | Packet type (see commands / responses below) |
| 1 | 2 B | protocolVersion |
uint16 | Must be 0x0001 |
| 3 | 4 B | nonce |
uint32 | micros() at send time — used for ordering |
| 7 | 2 B | headerCrc |
uint16 | CRC-16 of bytes 0–6 |
| 9 | 2 B | payloadCrc |
uint16 | CRC-16 of the payload bytes |
| 11 | 2 B | payloadSize |
uint16 | Payload length in bytes |
| 13+ | N B | payload | — | Varies per type |
Bytes 0–6 (type + protocolVersion + nonce) form the inner PacketHeader struct that is covered by headerCrc.
CRC algorithm: CRC-16/IBM (reflected, poly 0xA001, init 0xFFFF) — same function throughout the firmware (Utils/Crc.hpp).
Malformed frames are silently dropped
The controller validates header CRC, payload CRC, and protocol version. There is no error response — if a command has no effect, check these three fields first.
1.2 Commands¶
Commands are sent client → controller. The controller does not reply unless a response is specified.
TOGGLE (type 1)¶
Turn a panel's LED on or off.
| Offset | Size | Field | Type | Description |
|---|---|---|---|---|
| 0 | 1 B | address |
uint8 | Panel index assigned during discovery |
| 1 | 1 B | state |
uint8 | 1 = on, 0 = off |
SET_COLOR (type 3)¶
Set a panel's LED colour directly.
| Offset | Size | Field | Type | Description |
|---|---|---|---|---|
| 0 | 1 B | address |
uint8 | Panel index |
| 1 | 1 B | r |
uint8 | Red channel |
| 2 | 1 B | g |
uint8 | Green channel |
| 3 | 1 B | b |
uint8 | Blue channel |
Running animations overwrite this on the next tick.
GET_PANELS_STATES (type 5)¶
Request the current state (on/off, colour) of all discovered panels. The controller replies with a PANELS_STATES response on the same client connection.
No payload.
GET_EDGES_LIST (type 4)¶
Request the full edge connectivity graph. The controller replies with an EDGES_LIST response.
No payload.
ANIMATION_TRIGGER (type 8)¶
Fire a low-latency reactive beat trigger. The controller broadcasts PACKET_ANIMATION_UPDATE_PARAMS via I²C General Call to all panels running a REACTIVE animation in the specified group. Round-trip from WebSocket frame to panels lighting up is typically under 5 ms.
| Offset | Size | Field | Type | Description |
|---|---|---|---|---|
| 0 | 1 B | groupId |
uint8 | Animation group to trigger (1–254) |
| 1 | 1 B | value |
uint8 | Peak level — 0 = off, 255 = full brightness flash |
No response is sent. For music sync at 120 BPM, fire this every 500 ms.
SET_MIRROR (type 10)¶
Enable or disable MIRROR_BATCH streaming for this client connection. Mirroring is off by default — the controller only sends MIRROR_BATCH frames to clients that have explicitly opted in.
| Offset | Size | Field | Type | Description |
|---|---|---|---|---|
| 0 | 1 B | enabled |
uint8 | 1 = enable streaming, 0 = disable |
When enabled=1, the controller immediately unicasts a state snapshot — a single MIRROR_BATCH containing the last-seen packet for each panel/type combination — before the live stream begins. This brings the client's visualizer to the correct current state without waiting for the next animation cycle.
When enabled=0, the controller stops sending MIRROR_BATCH frames to this client, saving network bandwidth and send-queue capacity.
No response is sent.
PING (type 11)¶
Liveness check. The controller replies immediately with a PONG on the same connection. Clients use this to confirm a WebSocket connection that reports as open is actually being served by a live controller — useful for periodic "is this device online" checks, since a TCP connection can remain in a connected state after the remote end has gone silent.
No payload.
1.3 Responses¶
Responses are sent controller → client in reply to query commands.
PANELS_STATES (type 6)¶
Sent in reply to GET_PANELS_STATES. payloadSize = 2 + N×7.
Payload header (2 bytes):
| Offset | Size | Field | Type | Description |
|---|---|---|---|---|
| 0 | 2 B | length |
uint16 | Number of panel state entries (N) |
Per entry × N (7 bytes each):
| Offset | Size | Field | Type | Description |
|---|---|---|---|---|
| 0 | 2 B | panelIndex |
uint16 | Panel index |
| 2 | 1 B | state |
uint8 | 1 = on, 0 = off |
| 3 | 1 B | color_r |
uint8 | Red channel |
| 4 | 1 B | color_g |
uint8 | Green channel |
| 5 | 1 B | color_b |
uint8 | Blue channel |
EDGES_LIST (type 7)¶
Sent in reply to GET_EDGES_LIST. payloadSize = 2 + N×8.
Payload header (2 bytes):
| Offset | Size | Field | Type | Description |
|---|---|---|---|---|
| 0 | 2 B | length |
uint16 | Total number of edge entries (N) |
Per entry × N (8 bytes each):
| Offset | Size | Field | Type | Description |
|---|---|---|---|---|
| 0 | 2 B | panelIndex |
uint16 | Source panel index |
| 2 | 2 B | edgeIndex |
uint16 | Edge slot on that panel (0–2) |
| 4 | 2 B | connectedPanelIndex |
uint16 | Peer panel index (0 if unoccupied) |
| 6 | 2 B | connectedEdgeIndex |
uint16 | Peer edge slot (0 if unoccupied) |
MIRROR_BATCH (type 9)¶
Streamed controller → client at up to ~30 fps once the client has sent SET_MIRROR(1). Each frame is a coalesced batch of all outbound I²C packets captured since the previous flush. The mobile app uses these to drive its per-panel AnimationPlayer for real-time preview.
Payload header (6 bytes):
| Offset | Size | Field | Type | Description |
|---|---|---|---|---|
| 0 | 4 B | controllerMillis |
uint32 | millis() on the controller at flush time (LE) |
| 4 | 2 B | count |
uint16 | Number of records that follow (LE) |
Per record × count:
| Offset | Size | Field | Type | Description |
|---|---|---|---|---|
| 0 | 1 B | address |
uint8 | I²C target panel index; 0 = General Call (all panels) |
| 1 | 1 B | type |
uint8 | Protocol::packetType_t value |
| 2 | 1 B | size |
uint8 | Byte length of packet |
| 3 | N B | packet |
bytes | Raw packet including 5-byte PacketMeta header |
All records in one frame share the same controllerMillis timestamp. The first frame received after enabling mirroring is a state snapshot (last-seen value per panel/type) rather than a live-stream flush — process it identically.
PONG (type 12)¶
Sent immediately in reply to PING, on the same connection.
No payload.
APP_STATE (type 13)¶
Broadcast controller → all connected clients whenever any field of GET /api/state changes (power, last-played scene, playback status, or speed). No client command triggers this frame.
payloadSize = 51.
| Offset | Size | Field | Type | Description |
|---|---|---|---|---|
| 0 | 1 B | isOn |
uint8 | 1 = panels on, 0 = off |
| 1 | 1 B | lastPlayedSceneIsStored |
uint8 | 1 if lastPlayedSceneId refers to a catalogued scene |
| 2 | 1 B | playing |
uint8 | 1 if a scene is currently playing |
| 3 | 1 B | _reserved |
uint8 | Always 0 |
| 4 | 4 B | speed |
float32 LE | Current scene speed multiplier (0.1–10.0) |
| 8 | 11 B | lastPlayedSceneId |
char[11] | Null-terminated scene id (max 10 chars) |
| 19 | 32 B | controllerFirmware |
char[32] | Null-terminated controller build version (FW_VERSION) |
1.4 Sending a command — worked example¶
To turn panel 3 on:
- Build
PacketHeader:type=1,protocolVersion=0x0001,nonce=<micros()> - Compute
headerCrc = crc16(&header, 7) - Build payload:
address=3,state=1(2 bytes) - Compute
payloadCrc = crc16(payload, 2) - Assemble
PacketMeta + payload(14 + 2 = 16 bytes total) - Send as a binary WebSocket frame
2. HTTP API¶
All endpoints return application/json. All ports are 80.
Mutating endpoints whose side effects emit I²C packets to panels — scene play/stop/speed, one-shot
play, animation trigger, appearance PATCH, per-panel on/color, power, and configuration logicalRoot — validate
synchronously then queue the work onto the main loop, returning 202 Accepted. The change is
applied on the next main-loop tick (sub-millisecond later); validation failures (4xx) are still
returned synchronously, and a full queue returns 503 busy. Read-only endpoints and pure
filesystem/config mutations (scene/palette save & delete, configuration PATCH without logicalRoot) return 200.
2.1 Appearance¶
| Method | Path | Body | Response |
|---|---|---|---|
GET |
/api/appearance |
— | {"brightness":N,"baseColors":["#..","#..","#.."],"palette":"..."} |
PATCH |
/api/appearance |
Any subset of the three fields | 202 {} (applied on the main loop) |
Persists to /config/appearance.db (a single binary Database record) and broadcasts the updated value to all panels immediately.
2.2 Palettes¶
Palettes are stored in a binary database at /data/palettes.db (via PaletteStore / PaletteRepository). Each palette is keyed by its name (unique, case-insensitive comparison; stored with original casing). Built-in palettes are seeded on first boot.
| Method | Path | Body | Response |
|---|---|---|---|
GET |
/api/palettes |
— | Full palette array with stops (Base colors stops synthesized from current appearance) |
GET |
/api/palettes/:name |
— | Full palette JSON with stops; "Base colors" returns stops synthesized from current appearance base colors |
POST |
/api/palettes |
Palette JSON (create only) | {"name":"…"}; 409 name_exists if the name is taken |
PUT |
/api/palettes/:name |
Palette JSON (stops only; name in body must match path) |
{}; 403 if built-in |
DELETE |
/api/palettes/:name |
— | 204; 403 if built-in or "Base colors" |
Path name: URL-encoded, 1–30 chars. Scene and appearance palette fields reference palette names (not ids).
Built-in names (cannot be deleted or overwritten): Rainbow, Lava, Ocean, Forest, Party, Sunset, Aurora, Embers, and the virtual Base colors palette (synthesized from appearance base colors — not stored in the database).
Palette JSON format:
{
"schemaVersion": 1,
"name": "My Cool Palette",
"stops": [
[0, "#000000"],
[128, "#FF4400"],
[255, "#FFFFFF"]
]
}
2.3 Scenes¶
Scenes are stored in a binary database at /data/scenes.db (via SceneStore). Each scene is keyed by its id (8–10 chars, lowercase [a-z0-9]). List metadata (name, layerCount, duration) is stored in the record alongside the parsed scene body. The one-shot play slot uses a fixed hidden id (not listed by GET /api/scenes). HTTP request/response bodies remain scene JSON.
Scene library¶
| Method | Path | Body | Response |
|---|---|---|---|
POST |
/api/scenes |
Scene JSON (no id) |
{"id":"…"} |
PATCH |
/api/scenes/:id |
Scene JSON (no id in body) |
{"id":"…"} |
GET |
/api/scenes |
— | [{"schemaVersion":1,"id":"…","name":"…","layerCount":2,"duration":15000},…] |
GET |
/api/scenes/:id |
— | Scene JSON (chunked stream; includes embedded "id") |
DELETE |
/api/scenes/:id |
— | {} |
Scene path id: 8–10 chars, lowercase [a-z0-9]. Display name in JSON may be up to 64 chars. Scene palette fields reference palette names (e.g. "Lava", "Base colors").
Playback¶
| Method | Path | Body | Response |
|---|---|---|---|
POST |
/api/scenes/play/one-shot |
Full scene JSON — stored under internal one-shot id, then played | 202 {} |
POST |
/api/scenes/play |
— (replays lastPlayedSceneId: stored id, or one-shot id when lastPlayedSceneIsStored is false) |
202 {}, or 404 no_last_played_scene |
POST |
/api/scenes/:id/play |
— (plays stored scene by id) | 202 {} |
POST |
/api/scenes/stop |
— | 202 {} |
POST |
/api/scenes/speed |
{"speed": <float>} — set playback speed multiplier [0.1, 10.0] |
202 {"ok":true,"speed":2.0} |
Playback status (playing, speed) and lastPlayedSceneIsStored are reported via GET /api/state (§2.8).
2.4 Animations¶
One-shot animation¶
Plays a single-layer animation directly without saving to disk. Use this for notifications that overlay an ambient scene on a free group ID.
The body is a flat object — step fields (type/runner, color, duration, params, etc.) sit at the root level alongside group and panels. There is no "sequence" wrapper.
POST /api/animations/play
Content-Type: application/json
{
"group": 250,
"panels": "all",
"type": "PULSE",
"colorFrom": "#000000",
"colorTo": "#FF0000",
"duration": 600,
"params": [64, 128, 64]
}
For chained steps (e.g. pulse → fade-out), use POST /api/scenes/play/one-shot with a full scene body containing one layer with the desired sequence, and a free group ID.
Response: 202 {} (parsed synchronously; played on the main loop)
Reactive trigger (HTTP alternative to WebSocket)¶
group accepts a numeric ID or the string name used in the scene JSON (e.g. "group": "layer5"). The name is resolved against the currently playing scene; group_not_found is returned if no loaded layer has that name.
Response: 202 {} (trigger applied on the main loop)
Tip
Use the WebSocket ANIMATION_TRIGGER (type 8) when latency matters — HTTP adds ~5 ms compared to the sub-millisecond path through the WebSocket handler.
2.5 Firmware updates¶
Upload and flash panel firmware¶
POST /api/firmware/panels
Content-Type: application/octet-stream
[raw binary body — panel firmware .bin file]
The body is streamed directly to /panel_fw.bin to avoid buffering in RAM. Once the upload completes, the controller starts flashing panels over I²C one by one in discovery order.
Responses:
| Code | Body | Meaning |
|---|---|---|
200 |
{"status":"flashing","panels":N} |
Upload accepted, flashing started |
409 |
{"error":"flash already in progress"} |
Another flash is already running |
422 |
{"error":"<msg>"} |
Write failure or other error |
507 |
{"error":"filesystem write failed"} |
Filesystem full or unavailable |
Restart required after flashing
After flashing all panels, the controller must be restarted so PanelsInitializer can re-run discovery with the new panel firmware.
Flash status¶
State values:
state |
Meaning |
|---|---|
"idle" |
No flash in progress |
"connecting" |
Entering bootloader on current panel |
"flashing" |
Writing firmware pages |
"verifying" |
Reading back to verify |
"done" |
All panels flashed successfully |
"error" |
Flash failed — see "error" field |
2.6 Panels¶
Direct per-panel control. These endpoints bypass the animation system — any running animation continues to compute and will overwrite the values on its next frame.
| Method | Path | Body | Response |
|---|---|---|---|
GET |
/api/panels |
— | [{"address":N,"on":true,"color":"#RRGGBB"},...] |
GET |
/api/panels/edges |
— | [{"panel":N,"edge":N,"connectedPanel":N,"connectedEdge":N},...] |
PUT |
/api/panels/:address/on |
{"value":1} |
202 {} |
PUT |
/api/panels/:address/color |
{"color":"#FF0000"} |
202 {} |
:address is the panel's I²C index as returned by GET /api/panels.
GET /api/panels fetches the live state of each discovered panel over I²C. Panels that do not respond are omitted from the array. connectedPanel and connectedEdge in the edges response are 0 when an edge slot is unoccupied.
These are the HTTP equivalents of the WebSocket TOGGLE, SET_COLOR, GET_PANELS_STATES, and GET_EDGES_LIST commands.
2.7 Configuration¶
Persistent app-level and per-device topology settings. Boot behaviour is stored in /config/configuration.db (a single binary Database record, written with a 5-second deferred-write window). Logical root is stored in /config/topology.json (written immediately on change).
| Method | Path | Body | Response |
|---|---|---|---|
GET |
/api/configuration |
— | {"powerStateOnBoot":0,"logicalRoot":5} |
PATCH |
/api/configuration |
Any subset of powerStateOnBoot, logicalRoot |
{}, or 202 {"ok":true,"logicalRoot":N} when logicalRoot is patched |
powerStateOnBoot — controls the global power state after a reboot:
| Value | Constant | Behavior |
|---|---|---|
0 |
POWER_ALWAYS_ON (default) |
Always boot with panels on |
1 |
POWER_ALWAYS_OFF |
Always boot with panels off |
2 |
POWER_LAST_STATE |
Restore the last persisted isOn value |
Values outside 0–2 return 422.
Logical root re-centres the rooted topology view (depth/subtree/canonical order and the default runner source:root). Patching it persists and restarts a playing scene so the new rooting applies immediately (202). A logicalRoot that doesn't exist on this device falls back to the physical root. Use 0 to reset to the physical root.
2.8 State¶
Runtime app state — power state, scene playback status, and the most recently played scene id.
Persisted in /config/app_state.db (a single binary Database record) with a 5-second deferred-write window. The initial
isOn value on boot is derived from powerStateOnBoot (see §2.7); lastPlayedSceneId /
lastPlayedSceneIsStored are set whenever a scene is played via POST /api/scenes/play/one-shot
or POST /api/scenes/:id/play (see §2.3 Scenes). lastPlayedSceneIsStored is false when
the replay target is the internal one-shot scene id rather than a catalogued scene.
| Method | Path | Body | Response |
|---|---|---|---|
GET |
/api/state |
— | {"isOn":true,"lastPlayedSceneId":"abcd1234","lastPlayedSceneIsStored":true,"playing":true,"speed":1.0,"controllerFirmware":"1.2.3"} |
POST |
/api/state/power |
{"isOn":bool} |
202 {"isOn":bool} (panel effects applied on the main loop) |
WebSocket clients receive an APP_STATE (type 13) broadcast whenever any field in the response above changes — see §1.3 APP_STATE.
- Setting
isOn: falsestops all animations, clears panel animation queues, and turns all panels off. Scene and animation play endpoints return409 system_offwhile off. - Setting
isOn: trueturns all panels back on and re-broadcasts the current appearance (brightness, palette, base colors). controllerFirmwareis the controller build version string (FW_VERSION); read-only, useful for the mobile app to show the running firmware.
2.9 MQTT (Home Assistant)¶
ESP32 controllers only (controller_esp32, controller_s2_mini). MQTT is not compiled for ESP8266 targets.
Optional integration with a local MQTT broker (no TLS in v1). When enabled, the controller publishes Home Assistant MQTT discovery configs and state topics, and subscribes to command topics. Configuration is stored in /config/mqtt.db.
Configure via:
- Captive portal — WiFi setup form includes an MQTT (Home Assistant) section (
Enable MQTT, broker discovery mode, broker host, port, username, password). - HTTP —
GET/PATCH /api/mqtt.
| Method | Path | Body | Response |
|---|---|---|---|
GET |
/api/mqtt |
— | See fields below |
PATCH |
/api/mqtt |
Partial update; password omitted in GET; empty password leaves the stored password unchanged |
Same as GET |
Config fields
| Field | Type | Notes |
|---|---|---|
enabled |
bool | Master switch |
brokerDiscovery |
uint | 0 manual, 1 auto, 2 mDNS only (_mqtt._tcp), 3 HA host fallback only |
broker |
string | Manual broker hostname/IP. When non-empty, discovery is skipped regardless of brokerDiscovery |
port |
uint | Default 1883; used for manual broker and as fallback when mDNS SRV omits a port |
username / password |
string | Broker credentials; password never returned on GET |
topicPrefix |
string | Default lightnet/{deviceId} |
discoveryPrefix |
string | HA discovery root; default homeassistant |
Runtime fields (GET only)
| Field | Type | Notes |
|---|---|---|
connected |
bool | MQTT session state |
brokerDiscoveryState |
string | idle, searching, done, or failed |
resolvedBroker |
string | Host used for the current/recent connection attempt |
resolvedPort |
uint | Port paired with resolvedBroker |
discoverySource |
string | none, manual, mdns, homeassistant, or hassio |
Broker discovery
When brokerDiscovery is not manual and broker is empty, the controller resolves the broker asynchronously in the main loop (non-blocking ESP-IDF mDNS):
- Auto (
1) — browse_mqtt._tcp, then resolvehomeassistant.local, thenhassio.local(port1883). - mDNS only (
2) —_mqtt._tcpbrowse only. - HA host only (
3) —homeassistant/hassioA-record lookup only.
A non-empty broker field always wins (manual override). Patching config or disabling MQTT clears the cached endpoint and triggers a fresh resolve on reconnect. broker is required when enabled is true and brokerDiscovery is manual (422 broker_required).
Patching config triggers an MQTT reconnect on the next main-loop tick.
Default topic prefix: lightnet/{deviceId} where deviceId matches the mDNS hostname (e.g. lightnet-a1b2c3d4).
| Topic suffix | Direction | Payload |
|---|---|---|
status |
pub (LWT + birth) | offline / online |
state/power |
pub | ON / OFF |
command/power |
sub | ON / OFF |
state/brightness |
pub | 0–255 |
command/brightness |
sub | 0–255 |
state/scene |
pub | scene id or none |
command/scene |
sub | scene id or none (stop) |
Home Assistant scene select discovery lists options as "Name (id)" and uses command_template / value_template so the HA UI shows the label while state/scene and command/scene payloads remain the raw scene id.
| state/playing | pub | true / false |
| state/speed | pub | float string (e.g. 1.0) |
| command/stop | sub | any payload → stop scene |
Home Assistant discovery (retained, under {discoveryPrefix}/, default homeassistant/): switch (power), light (brightness + on/off), select (scene list as "Name (id)" with id on the wire), binary sensor (playing), sensor (speed).
MQTT commands that mutate panel/scene state are deferred through MainLoopQueue like HTTP. Brightness and scene commands are ignored while isOn is false (same as HTTP 409 system_off behaviour, but without an error reply on MQTT).
3. Error Handling¶
HTTP¶
| Code | Meaning |
|---|---|
200 {} |
Success (read-only or filesystem/config mutation) |
202 {} |
Accepted — validated; the packet-emitting work was queued onto the main loop |
404 {"error":"not_found"} |
Named scene or palette does not exist |
503 {"error":"busy"} |
Main-loop work queue is full; retry shortly |
409 {"error":"..."} |
Conflict — flash already running, or scene schema version too new for this firmware |
422 {"error":"..."} |
Validation failure — the request body is invalid; no state was changed |
403 {"error":"..."} |
Operation not permitted (e.g. deleting a built-in palette) |
500 {"error":"..."} |
Filesystem read/write failed |
507 {"error":"..."} |
Insufficient storage (firmware upload endpoint only) |
WebSocket¶
Silent drops
The controller silently drops frames that fail validation (size, CRC, version). There is no error response. If a command has no visible effect, verify:
protocolVersionis0x0001- Header CRC covers the first 7 bytes (
type+protocolVersion+nonce) - Payload CRC covers only the payload bytes (not
PacketMeta) - Panel address is a valid panel index (1-based, as returned by GET_PANELS_STATES /
GET /api/panels)