Skip to content

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>.localJSON, 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

  1. WebSocket Binary API
  2. Packet structure
  3. Commands — TOGGLE (1), SET_COLOR (3), GET_PANELS_STATES (5), GET_EDGES_LIST (4), ANIMATION_TRIGGER (8), SET_MIRROR (10), PING (11)
  4. Responses — PANELS_STATES (6), EDGES_LIST (7), MIRROR_BATCH (9), PONG (12), APP_STATE (13)
  5. Sending a command — worked example
  6. HTTP API
  7. Appearance
  8. Palettes
  9. Scenes
  10. Animations
  11. Firmware updates
  12. Panels
  13. Configuration
  14. State
  15. MQTT (Home Assistant)
  16. 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.110.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:

  1. Build PacketHeader: type=1, protocolVersion=0x0001, nonce=<micros()>
  2. Compute headerCrc = crc16(&header, 7)
  3. Build payload: address=3, state=1 (2 bytes)
  4. Compute payloadCrc = crc16(payload, 2)
  5. Assemble PacketMeta + payload (14 + 2 = 16 bytes total)
  6. 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 logicalRootvalidate 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:

JSON
{
  "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.

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

HTTP
POST /api/animations/trigger
Content-Type: application/json

{"group": 1, "value": 200}

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

HTTP
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

HTTP
GET /api/firmware/status
JSON
{
  "state": "flashing",
  "panel": 3,
  "total": 12,
  "progress": 45
}

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: false stops all animations, clears panel animation queues, and turns all panels off. Scene and animation play endpoints return 409 system_off while off.
  • Setting isOn: true turns all panels back on and re-broadcasts the current appearance (brightness, palette, base colors).
  • controllerFirmware is 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:

  1. Captive portal — WiFi setup form includes an MQTT (Home Assistant) section (Enable MQTT, broker discovery mode, broker host, port, username, password).
  2. HTTPGET/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):

  1. Auto (1) — browse _mqtt._tcp, then resolve homeassistant.local, then hassio.local (port 1883).
  2. mDNS only (2)_mqtt._tcp browse only.
  3. HA host only (3)homeassistant / hassio A-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 0255
command/brightness sub 0255
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:

  1. protocolVersion is 0x0001
  2. Header CRC covers the first 7 bytes (type + protocolVersion + nonce)
  3. Payload CRC covers only the payload bytes (not PacketMeta)
  4. Panel address is a valid panel index (1-based, as returned by GET_PANELS_STATES / GET /api/panels)