Documentation

Protocol contract

This document is the canonical wire contract between any client and a PlaiiinLightOS lamp. The firmware is licensed under Apache 2.0 (lampos/LICENSE); the protocol below is the surface anyone can target to build their own client app, command-line tool, or home-automation bridge.

If you find a behavior in the official apps that isn't documented here, it is not part of the contract — please open an issue.

Status. Stable. apiVersion is a semantic version (currently "1.6.0"); the major component bumps on a breaking change, minor on additive ones. Breaking changes are announced in this document's changelog.

Companion docs (authoritative for each subsystem)

  • sharing-api.md — role-based access control, share-key endpoints, multi-user model.
  • wormhole-api.md — render-mode contract for wormhole-form lamps, ring geometry, /api/wormhole endpoints.
  • ble-share.md — client-to-client BLE share-key handoff. Does not involve the firmware.

The current file is the entry point; the specs above are normative for their respective topics.

Transports

A lamp speaks three transports. Every client uses some subset.

Transport Default port When to use
HTTP (REST) 80 Discovery, config, state, scripts, OTA.
WebSocket 80 at /ws Continuous pixel streaming. Binary frames.
BLE GATT n/a Onboarding (no WiFi yet), low-power control.

mDNS: the lamp advertises on both _plaiiinlight._tcp and _http._tcp (Bonjour / DNS-SD). The TXT record carries vendor, node (node name), fw (firmware version), api (API version), lamp (lamp type), path (/api), paired (0/1), and id (stable per-lamp id — the factory MAC as 12 lowercase hex; survives renames). Richer identity (form, geometry, LED count) is read from GET /api after discovery.

A lamp that's a swarm member also speaks a fourth, lamp-to-lamp-only wire format over raw ESP-NOW broadcast — not a client transport, so it isn't in the table above. See Swarm mode below.

Authentication

A lamp is in one of two pairing modes:

  • unpaired (default) — no auth. Any client on the LAN may call any endpoint.
  • paired — every request must carry the bearer token from POST /api/pair. WebSocket clients pass the token via the ?token=<…> query parameter on the upgrade request.

sharing-api.md defines a finer-grained role model (user, creator, admin) layered on top, plus the multi-key store and /api/whoami. New clients should target the role model; legacy "admin only" behavior is what a single pair_token gets you.

The stable id (id over BLE/mDNS, deviceId over HTTP) is the factory MAC, not a secret, and must never be used as an authentication or authorization token. Likewise, clients trust mDNS identity (name, host, id) only as far as the LAN itself is trusted — a spoofed record can redirect a client to the wrong host, which is the same trust already placed in the plain name→host mapping.

HTTP API

Stable endpoints. Newer endpoints are added in the companion specs.

Discovery & state

Method Path Returns / Body
GET /api Device info — vendor, apiVersion, firmwareVersion, nodeName, deviceId, ledPin, ledClkPin, ledCount, ledType, lampType, lampForm, modelName, modelVersion, physicalW/H, logicalW/H, pixelGroupW/H, rotation, origin, serpentine, serpentineAxis, button pins.
GET /api/state {on, color:[r,g,b], mode, brightness, currentScript?}
GET /api/whoami {role, paired} — see sharing-api.
GET /api/pair {paired:bool, hasToken:bool} (no auth).
POST /api/pair Mint or rotate the admin token. Body empty when unpaired (bootstrap), or Authorization: Bearer <current> when paired (rotate). Returns {token}.
DELETE /api/pair Unpair the lamp. Requires the current admin token.

Control

Method Path Body
POST /api/power {on: bool}
POST /api/color {colors: [[r,g,b], …]} — one entry per logical LED (clamped 0–255).
POST /api/brightness {brightness: 0..255} (persisted to NVS).
GET /api/brightness {brightness}
POST /api/mode {mode: "stream" | "api" | "js" | "frame"}
GET /api/limits {maxBrightness, maxCurrentMa, pixelGroupW, pixelGroupH}
POST /api/limits Any subset of the four fields.
GET /api/base_color {color:[r,g,b]} — the base color JS scripts blend onto (read-only; set it via /api/color).
GET /api/fade {onMs, offMs} — power-on / power-off fade durations.
POST /api/fade Any subset of {onMs, offMs}.
GET /api/ap_js {name} — script auto-played while the lamp is in provisioning-AP mode.
POST /api/ap_js {name}

Local JS scripts

Method Path Body / Returns
GET /api/js {scripts:[…], playing:<name>|null}
GET /api/js/<name> Raw JS source.
PUT /api/js/<name> Raw JS source. Validated + eval'd once before write.
DELETE /api/js/<name> —
POST /api/play {file:"<name>", fps:<n>} — load and run.
POST /api/stop Stop JS playback.

The script contract is function shade(x, y, idx, frame, base, params) — each call emits one pixel via emit() / emitBright() / emitHSV(). The shared lampos/portal/shade-runtime.js runtime is the reference implementation used by the firmware and every client's preview.

Draw mode (/api/frame)

frame is a fourth value for mode (alongside stream, api, js): a single still image, uploaded by a client and remembered by the lamp. Enter it with the existing POST /api/mode {"mode":"frame"} — no separate endpoint. It is persisted like the other modes (survives reboot; the stored frame is repainted on power-on) and is suspended for the duration of any live WebSocket stream, resuming the moment the stream closes. Entering frame mode stops the local JS player.

baseColor behaves exactly as it does in js mode: POST /api/color — body {colors:[[r,g,b], …]} in every case, a single-entry array ({colors:[[r,g,b]]}) for a "solid color" update — updates and persists baseColor from the first entry, and it is returned by GET /api/base_color / GET /api/state, but it does not repaint the panel — the drawn frame stays on screen.

Method Path Body / Returns
POST /api/frame Raw application/octet-stream body, exactly logicalW * logicalH * 3 bytes — row-major RGB over the logical grid (pixel-group tiling, rotation, and serpentine wiring are applied by the firmware, not the client). Requires creator role or above.
GET /api/frame application/octet-stream — the stored frame in the same raw layout, with X-Frame-W / X-Frame-H response headers giving its width/height. Requires user role or above.

POST /api/frame:

  • Wrong body length → 400 with {"status":"error","message":"expected N bytes (WxHx3)"}, where N, W, H reflect the lamp's current logical grid. The frame is not saved.
  • Correct length → saved to flash. Returns {"status":"ok","w":W,"h":H}. The panel repaints immediately only if the lamp is already in frame mode, powered on, and not currently receiving a WebSocket stream; otherwise the save is silent until the lamp next enters/resumes frame mode.

GET /api/frame:

  • No frame has ever been saved, or the stored geometry doesn't match the lamp's current logical grid (e.g. pixel grouping changed since the frame was drawn) → 404 with {"status":"error","message":"no frame stored"}. A geometry mismatch is treated identically to "nothing stored" — the lamp never paints a frame at the wrong size.
  • Otherwise → 200 with the raw bytes and the X-Frame-W/X-Frame-H headers.

On-flash format. The lamp keeps at most one frame, at /storage/frame.bin: 4-byte magic "PLFR", then width and height as u16 little-endian, then the raw row-major RGB payload (width * height * 3 bytes, no padding). The loader rejects the file (same as "not found") if the magic doesn't match or the stored width/height differ from the lamp's current logical grid.

Wormhole lamps. Unlike streamed or js content, frame mode paints the logical grid directly on wormhole-form lamps — there is no per-ring mirror-tiling step. This is a deliberate simplification; see wormhole-api.md for how the other render modes differ.

Clients. The macOS/iOS/Android apps' Draw tab (creator role or above) is the reference producer, but /api/frame is a normal part of the wire contract — any client may implement its own editor against it.

Saved images (/api/images)

The lamp keeps a small library of saved drawings (max 30, ~1.5 KB each) on the same SPIFFS partition, in the same binary layout as the frame store.

Method Path Description
GET /api/images {"status":"ok","images":[{"name":"img-001","w":16,"h":8}],"count":1,"max":30} — user role.
POST /api/images Snapshot the currently stored frame into the lowest free slot; returns {"status":"ok","name":"img-001"}. 404 no frame stored, 409 image store full. Creator role.
GET /api/images/{name} Raw RGB octet-stream + X-Frame-W/X-Frame-H headers — same shape as GET /api/frame. User role.
POST /api/images/{name}/show Copy the image into the frame store, display it, persist mode frame. 400 geometry mismatch if it was drawn on a different grid. Creator role.
DELETE /api/images/{name} Remove the image. Creator role.

Saved images survive reboots and OTA but are wiped by a full factory reset. The built-in carousel effect (see /api/js) cycles through them.

Updates & reset

Method Path Body
POST /api/ota application/octet-stream firmware image.
GET /api/ota/info {version, buildDate, partition, idfVersion}
POST /api/reset {scope: "wifi" | "full"}

Swarm provisioning (/api/swarm*)

Admin role. These endpoints provision and monitor a lamp's swarm membership — the lamp-to-lamp ESP-NOW propagation described in full under Swarm mode below. id is 16 hex characters (8 raw bytes); key is 64 hex characters (32 raw bytes, the HMAC-SHA256 key); channel is a WiFi channel number (1–14) or 0 for "unset/use whatever channel the radio is already on."

Method Path Body / Returns
GET /api/swarm {"member":bool,"id":"<16hex or empty>","enabled":bool,"channel":N} — the key is never returned.
POST /api/swarm {"id":"<16hex>","key":"<64hex>","channel":N} → join (implies enable). 200 {"status":"ok"}; 400 {"status":"error","message":"invalid id/key/channel"} on malformed input. 409 when the lamp has no WiFi (STA) connection and channel is 0/absent — an AP-less lamp can't pin the swarm channel; send an explicit channel to join anyway.
DELETE /api/swarm Leave: erases the swarm identity (sw_id, sw_key) and disables (sw_on). Always 200 {"status":"ok"}, even when already not a member.
POST /api/swarm/enable {"enabled":bool} — toggle participation without losing membership. 200 {"status":"ok"}; 400 {"status":"error","message":"not a swarm member"} when enabling a non-member (disabling a non-member is a harmless no-op, 200).
GET /api/swarm/stats {"tx":n,"rx":n,"txFail":n,"lastFrom":"aa:bb:cc:dd:ee:ff","dropAuth":n,"dropReplay":n,"relayed":n,"applied":n,"stackFree":n} — link-layer counters (tx/rx/txFail/lastFrom) plus protocol-layer counters (dropAuth/dropReplay/relayed/applied), plus stackFree: the swarm worker task's FreeRTOS stack high-water mark in bytes (0 if the worker isn't running).
POST /api/swarm/ping Debug only: broadcasts a 32-byte plaintext test packet ("PLSW-SPIKE" + sender MAC + sequence, zero-padded) and bumps tx. Not part of the authenticated protocol below.

Config pages (HTML)

The lamp also serves HTML pages for direct browser use: /, /control, /compose, /stream, /js, /config, /network, /mqtt, /ota. These are convenience UIs, not part of the protocol contract.

WebSocket streaming (/ws)

Connect to ws://<host>/ws (or wss:// if you add a reverse proxy). For paired lamps, the token goes in the URL: ws://<host>/ws?token=<…>.

Binary protocol — each frame is one command:

Byte(s) Description
0 Command. 0x01 color frame · 0x02 power · 0x03 clear.
1..2 (0x01 only) LED count, big-endian uint16.
3.. (0x01 only) RGB data, 3 bytes per LED.

Power command: [0x02, 0x00] = off · [0x02, 0x01] = on. Clear command: [0x03] (no payload).

LEDs auto-enable on the first color frame (no flicker on connect).

Wormhole lamps route streamed frames through a per-ring expand step; see wormhole-api.md for the frame-size contract and the close codes the server uses on mode changes.

BLE GATT

Used primarily for onboarding (no WiFi yet) and low-power control.

All characteristics live under the vendor service 4d9b71c0-1f8e-4a1f-9b8c-3d2e1a0e5c00; each characteristic's 128-bit UUID is that base with the last byte replaced by the suffix below (e.g. device_info = …5c01). Access: R read · W write · N notify. (Source of truth: main/bt_service.c DEF_UUID(...) and the characteristic table — keep this in sync with it.)

Characteristic Suffix Access Purpose
device_info 01 R JSON shape matching GET /api, except the stable id key is id here (and over mDNS) rather than the HTTP response's deviceId.
wifi_scan 02 R W N Write to start a WiFi scan; read/notify the result list.
wifi_config 03 W SoftAP onboarding — set home SSID + password.
wifi_status 04 R N WiFi connection status.
power 05 R W Single byte on/off.
color 06 R W r g b (3 bytes).
mode 07 R W One of api · js · stream · frame.
current_script 08 R W N Current script name (read encrypted when paired).
play_next 09 W Advance to the next script.
play_prev 0a W Back to the previous script.
upload_meta 0b W Chunked script upload — metadata.
upload_data 0c W Chunked script upload — data chunks.
upload_status 0d R N Script-upload progress / status.
pair_token 0e R Hand the HTTP bearer token to a bonded peer (read encrypted).
pair_claim 0f W Claim the lamp / mint a token (write encrypted).
pair_unpair 10 W Unpair (write encrypted).
brightness 11 R W Brightness (byte).
script_params 12 R W Script knobs / switches.
fps 13 R Current render FPS.
fetch_meta 14 R W Download a stored script — metadata.
fetch_data 15 R Download a stored script — data.
script_delete 16 W Delete a stored script.
script_stop 17 W Stop the current script.
device_name 18 W Set the node name.
form_set 19 W Set the lamp form.
ota_meta 1a W OTA firmware upload — metadata.
ota_data 1b W OTA firmware upload — data chunks.
ota_status 1c R N OTA progress / status.
factory_reset 1d W Factory reset.

Paired lamps require an encrypted/bonded link for every write (BLE_GATT_CHR_F_WRITE_ENC) and for the sensitive reads current_script and pair_token (READ_ENC); pair_claim / pair_unpair always require an encrypted write. Just-Works pairing is supported today; a passkey upgrade is on the roadmap.

ble-share.md documents a separate client-to-client BLE flow for handing share keys between phones — that one does not touch the firmware at all.

Swarm mode (ESP-NOW, PLSW v1)

This documents PlaiiinLightOS's lamp-to-lamp broadcast protocol byte for byte, so a third-party firmware or bridge can join a swarm without access to this repository. It is not a client-to-lamp transport — lamps speak it to each other over raw ESP-NOW broadcast (FF:FF:FF:FF:FF:FF), riding whatever WiFi channel the sender is on (the AP's channel while associated; the swarm's stored channel while AP-less). Clients provision swarms and read status over the ordinary HTTP surface — see Swarm provisioning above.

Packet format — "PLSW" v1

Raw ESP-NOW broadcast payload, little-endian multi-byte fields, ≤250 bytes total:

Offset Size Field Notes
0 4 magic ASCII PLSW
4 1 version 1
5 8 swarm id Raw bytes of the 16-hex swarm id (decoded, not ASCII hex)
13 6 origin Origin lamp's STA MAC address
19 4 seq u32 LE, strictly increasing per origin
23 1 hop 0 = original broadcast, 1 = relayed once
24 1 on 0|1
25 3 color r, g, b, one byte each
28 1 brightness 0–255
29 1 mode 0 = api, 1 = js, 2 = no-change (origin is in frame/stream/any other mode — receivers apply on/color/brightness only and skip mode+effect)
30 1 effect name length (L) 0–32
31 L effect name ASCII, no NUL terminator
31+L 16 HMAC HMAC-SHA256 over bytes [0, 31+L), keyed with the 32-byte swarm key, truncated to the first 16 bytes

HMAC coverage includes the hop byte. Offset 23 (hop) is inside the signed region, so a relaying lamp that flips hop from 0 to 1 must recompute the tag over the mutated bytes with the shared swarm key before re-broadcasting — the original origin's tag no longer validates once hop changes. A receiver verifies against whichever tag arrived with the packet, using a constant-time comparison of the 16-byte truncated HMAC.

Semantics

  • Snapshot, not delta. Every packet is the origin's complete current state at broadcast time.
  • Apply rules: receivers always apply on/off, brightness, and color (color updates baseColor, per the js/frame blend contract already documented above). Mode is applied only when it's api (0) or js (1); mode 2 means "skip mode and effect, apply the rest." When mode is js and the receiver has a saved script matching the effect name, it plays it; otherwise the receiver keeps its current effect/mode (the "color fallback") and still applies on/color/brightness. A power-off packet never restarts a stopped effect; a receiver already playing the named script is not restarted.
  • Coalescing: an origin coalesces local changes and broadcasts at most once per ~100 ms (≤10 Hz) rather than one packet per change.
  • Replay/de-dupe: a receiver tracks the last-seen seq per origin MAC (up to 16 origins, LRU-evicted) and accepts a packet only if seq is strictly greater than the last one accepted from that origin; an unrecognized origin is accepted and inserted. seq is persisted to NVS every 64 increments and restored as persisted + 64 at boot, so a rebooted origin never reuses a sequence number a peer has already accepted.
  • Relay-once flooding: a lamp that accepts a hop == 0 packet re-broadcasts it once with hop = 1 (and a recomputed tag, see above) after a random 10–49 ms jitter; hop == 1 packets are never relayed further. A lamp ignores packets whose origin MAC is its own.
  • Auth failure / wrong swarm: a packet whose magic, version, swarm id, or HMAC doesn't match is dropped silently and counted (dropAuth in /api/swarm/stats); a packet that fails the replay check is counted separately (dropReplay).
  • Channel: an associated lamp's ESP-NOW rides its AP's WiFi channel automatically. An AP-less lamp pins its radio to the channel value stored at join time. This path ships unverified on hardware — both bench lamps used for verification stayed associated to the same home AP.
  • Best-effort delivery: ESP-NOW broadcast has no ack/retry; a dropped packet is not resent, but because packets carry full snapshots, the next change (or the next coalesced broadcast) self-heals any missed state.
  • Multi-hop caveat: relay-once flooding is implemented and shipped, but hardware verification so far covers only a 2-lamp bench (single hop, no relay traversal actually exercised).

Form factors & geometry

A lamp's lampForm field is one of tower, display, wormhole, strip, cube, rocket (and a small open set of others). The lamp's model is modelName + modelVersion (tower8 + v2 = tower8v2), which is what clients display; a profile's filename is exactly that model. The model is not the lamp's name: a freshly burned lamp boots as <nodeName>-<MAC suffix> (tower8-3FA8, the same suffix as its provisioning AP) so several lamps of one model stay distinguishable, and the owner can rename it freely afterwards. A full factory reset restores the name from modelName. Lamps that only OTA onto this firmware keep their existing name and seed modelName from it with an empty modelVersion; older firmware omits both keys, in which case clients fall back to lampForm. For matrix lamps, logicalW/H and physicalW/H may differ when pixel grouping is set — clients build frames at the logical size, the firmware tiles each logical pixel onto a pixelGroupW × pixelGroupH block of physical LEDs.

Wormholes are special: render geometry and physical geometry differ by construction. See wormhole-api.md.

Errors

The HTTP API uses standard status codes:

  • 400 — bad request body.
  • 401 — missing / invalid token (paired mode).
  • 403 — token valid but role insufficient (sharing-api.md).
  • 404 — endpoint not applicable for this lamp form.
  • 409 — request conflicts with current state (e.g. mirror mode on a non-wormhole, OTA in progress).
  • 5xx — firmware error; usually transient.

WebSocket close codes follow RFC 6455 (1000 normal) plus application-specific codes documented in wormhole-api.md.

Versioning

GET /api returns apiVersion (currently "1.6.0"). The contract is additive within a major version — new fields may appear, existing ones will not change meaning. A breaking change increments the major component.

Reference implementation

The firmware in this repository implements every endpoint described above — start in main/ and follow the route registrations there. The shared shade() runtime that powers JS scripts is in portal/shade-runtime.js (reference emulator) and components/plbc/ (the on-device bytecode VM).

This page is generated from docs/protocol.md in the firmware repository, which stays the source of truth. View it on GitHub.