Skip to content

API & Examples

Global appearance control, the full HTTP endpoint surface, WebSocket triggers, one-shot notifications, five worked scene examples, and the validation rule appendix.


Global Appearance Control

Three values define the current look of all panels, independent of any playing scene:

Setting Range Description
Brightness 0–255 Global multiplier applied on top of every animation's per-frame brightness
Base Colors 3 × #RRGGBB Primary, secondary, tertiary colours used by the "Base colors" palette
Palette name string Currently active gradient palette (e.g. "Lava")

These are persisted in /config/appearance.db and restored on every boot. They take effect immediately — the panel resolves all animation colours at frame time using the current palette/base-colours, so a mid-flight change appears on the very next rendered frame without restarting anything.

Persistence file

JSON
{
  "schemaVersion": 1,
  "brightness": 192,
  "baseColors": ["#FF4400", "#FF8800", "#000000"],
  "palette": "Lava"
}

Scene play does not persist appearance

Playing a scene does not change or persist the global appearance state. The scene's "colors" and "palette" fields define the colour context for that scene's animations only. To change the persistent appearance alongside a scene play, call the appearance endpoints explicitly before or after.

HTTP endpoints

HTTP
GET    /api/appearance
PATCH  /api/appearance        {"brightness":192, "baseColors":["#FF4400","#FF8800","#000000"], "palette":"Lava"}

All fields are optional — omit any you don't want to change. Returns 202 {} on success (validated synchronously; the broadcast to panels is applied on the next main-loop tick), 422 {"error":"..."} on invalid input, and persists to the filesystem.


HTTP API Reference

All endpoints are on port 80 (http://lightnet-<chipid>.local).

Appearance

Method Path Body / Response
GET /api/appearance {"brightness":N,"baseColors":["#..","#..","#.."],"palette":"..."}
PATCH /api/appearance Any subset of brightness, baseColors, palette — returns 202 {} (broadcast applied on the main loop)

Palettes (library management)

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
POST /api/palettes Create palette JSON{"name":"…"}; 409 if name exists
PUT /api/palettes/:name Update stops only (name in body must match path); 403 for built-ins
DELETE /api/palettes/:name 204; 403 for built-ins and "Base colors"

Path segment is URL-encoded (e.g. Base%20colors). Names are case-sensitive, max 30 chars.

Scenes (scene library)

Stored in /data/scenes.db via SceneStore. See docs/api.md for the canonical endpoint table.

Method Path Body / Response
POST /api/scenes Scene JSON body (no "id") — returns {"id":"…"}
PATCH /api/scenes/:id Scene JSON body (no "id") — returns {"id":"…"}
GET /api/scenes [{"schemaVersion":1,"id":"…","name":"…","layerCount":2,"duration":15000},…]
GET /api/scenes/:id Scene JSON (serialized from stored record)
DELETE /api/scenes/:id

Scene playback

Method Path Body / Response
POST /api/scenes/play/one-shot Full scene JSON — stored under a fixed hidden one-shot id, then played
POST /api/scenes/play — replays lastPlayedSceneId from GET /api/state
POST /api/scenes/:id/play — (plays stored scene by id)
POST /api/scenes/stop
POST /api/scenes/speed {"speed":<float>} — change playback speed [0.1, 10.0] while playing

Playback status (playing, speed) and lastPlayedSceneIsStored are reported via GET /api/state (see docs/api.md).

One-shot / triggers

Method Path Body / Response
POST /api/animations/play Flat layer object: {"group":N,"panels":..., "type":"BREATHE","color":"#FF0000","duration":2000}
POST /api/animations/trigger {"group":1,"value":200} — fires a REACTIVE beat

Error responses

Code Meaning
200 {} Success (read-only or filesystem/config mutation)
202 {} Accepted — validated; the packet-emitting work was queued onto the main loop (scene play/stop/speed, one-shot, trigger, appearance)
404 {"error":"not_found"} Scene / palette doesn't exist
409 {"error":"schema_too_new","scene":N,"firmware":M} Scene file has a newer schema version than the firmware supports
422 {"error":"<message>"} Validation failure — no changes applied

WebSocket Triggers

Connect to ws://lightnet-<chipid>.local/ws using the binary WebsocketApi protocol.

To fire a beat trigger for a REACTIVE animation on group 1 with peak level 200:

Text Only
MSG_ANIMATION_TRIGGER (type=8) payload:
  uint8_t groupId = 1
  uint8_t value   = 200   // 0-255 peak level

The controller broadcasts PACKET_ANIMATION_UPDATE_PARAMS with PARAM_TRIGGER to all panels in that group. Each panel running a REACTIVE animation on that group instantly jumps to colorTo and begins decaying toward colorFrom at its configured decayRate.

For music sync, fire triggers on beat events. At 120 BPM the inter-beat window is 500 ms. The controller spends only ~140 µs of I²C time per trigger; between triggers there is zero I²C traffic.


Notifications & One-Shot Animations

The scene player is single-instance — only one scene plays at a time. Notifications that should appear over an ambient scene use a free group ID that doesn't conflict with the scene.

Use POST /api/animations/play to send a single animation step directly, bypassing the scene system. The body is a flat object — all step fields live at the root alongside group and panels:

JSON
{
  "group": 250,
  "panels": "all",
  "type": "PULSE",
  "colorFrom": "#000000",
  "colorTo": "#FF0000",
  "duration": 500,
  "params": [
    64,
    64
  ]
}

All the same step fields documented in Animation Types and Controller Runners are supported. The notification runs on group 250 while the ambient scene continues on groups 1–N. The panel's AnimationPlayer handles both groups independently.

For chained steps on a notification (e.g. pulse → fade), use a short scene via POST /api/scenes/play/one-shot with a free group ID instead.


Examples

Example 1 — Ambient breathe (single colour)

JSON
{
  "name": "warm-breathe",
  "loop": true,
  "colors": {
    "primary": "#FF8800"
  },
  "palette": "Base colors",
  "layers": [
    {
      "group": 1,
      "panels": "all",
      "sequence": [
        {
          "type": "BREATHE",
          "colorFrom": "#000000",
          "colorTo": { "useColor": 0 },
          "duration": 4000,
          "loop": true
        }
      ]
    }
  ]
}

Change colour mid-flight: PATCH /api/appearance {"baseColors":["#0044FF","#FF8800","#000000"]} — the breathe immediately shifts to blue on the next frame.


Example 2 — Colour wash with wave overlay

JSON
{
  "name": "lava-wave",
  "loop": true,
  "palette": "Lava",
  "layers": [
    {
      "group": 1,
      "panels": "all",
      "sequence": [
        {
          "type": "SOLID",
          "color": { "palette": 128 }
        }
      ]
    },
    {
      "group": 2,
      "panels": "all",
      "sequence": [
        {
          "runner": "WAVE",
          "color": { "palette": 220 },
          "duration": 4000,
          "params": [4]
        },
        {
          "type": "SOLID",
          "color": { "palette": 128 },
          "duration": 1000
        }
      ]
    }
  ]
}

Group 1 holds a static dim background. Group 2 cycles: bright wave sweeps across, then holds the background level for a beat, then repeats.


Example 3 — Music-reactive fire

JSON
{
  "name": "fire-reactive",
  "loop": true,
  "palette": "Embers",
  "layers": [
    {
      "group": 1,
      "panels": "all",
      "sequence": [
        {
          "type": "REACTIVE",
          "colorFrom": { "palette": 50 },
          "colorTo": { "palette": 220 },
          "duration": 0,
          "params": [210]
        }
      ]
    }
  ]
}

Send WebSocket trigger on every beat: MSG_ANIMATION_TRIGGER group=1 value=255. Panels jump to the bright orange end of the embers palette and decay over ~1.2 s.


Example 4 — Scene with two spatial zones, different palettes

JSON
{
  "name": "split-zones",
  "loop": true,
  "layers": [
    {
      "group": 1,
      "panels": [1, 2, 3, 4, 5],
      "palette": "Ocean",
      "sequence": [
        {
          "type": "HUE_CYCLE",
          "duration": 0,
          "loop": true,
          "params": [8]
        }
      ]
    },
    {
      "group": 2,
      "panels": [6, 7, 8, 9, 10],
      "palette": "Lava",
      "sequence": [
        {
          "type": "BREATHE",
          "colorFrom": "#000000",
          "colorTo": { "palette": 200 },
          "duration": 3000,
          "loop": true
        }
      ]
    }
  ]
}

Panels 1–5 cycle through ocean hues. Panels 6–10 breathe lava orange. Spatial palette override is valid because the two panel sets don't overlap.


Example 5 — Boot-up sequence that settles into ambient

JSON
{
  "name": "startup",
  "loop": false,
  "palette": "Rainbow",
  "layers": [
    {
      "group": 1,
      "panels": "all",
      "sequence": [
        {
          "runner": "WAVE",
          "color": { "palette": 0 },
          "duration": 1500,
          "params": [5]
        },
        {
          "runner": "RIPPLE",
          "color": { "palette": 128 },
          "duration": 1200,
          "params": [3, 0]
        },
        {
          "type": "TRANSITION",
          "colorFrom": { "palette": 200 },
          "colorTo": { "useColor": 0 },
          "duration": 2000
        },
        {
          "type": "BREATHE",
          "colorFrom": "#000000",
          "colorTo": { "useColor": 0 },
          "duration": 0
        }
      ]
    }
  ]
}

Play once (loop: false). The sequence runs wave → ripple → colour transition → infinite breathe in the base colour.

Example 6 — Full choreography (startAfter, gap, async, barrier)

Every timing mechanism in one looping scene — parallel start, startAfter sequencing, a leading gap, the phase-locked scene-cycle barrier, and an async free-runner:

JSON
{
  "name": "choreo_demo",
  "loop": true,
  "layers": [
    {
      "group": "intro",
      "sequence": [
        {
          "type": "FADE",
          "colorFrom": "#00000",
          "colorTo": "#FFFFFF",
          "duration": 2000
        }
      ]
    },
    {
      "group": "accent",
      "sequence": [
        {
          "runner": "WAVE",
          "color": { "palette": 200 },
          "duration": 2000
        }
      ]
    },
    {
      "group": "main",
      "startAfter": "intro",
      "sequence": [
        { "duration": 500 },
        { "type": "BREATHE", "colorTo": "#0040FF", "duration": 3000 }
      ]
    },
    {
      "group": "bg",
      "async": true,
      "sequence": [
        {
          "type": "BLINK",
          "colorTo": "#FF8000",
          "duration": 1500,
          "params": [200]
        }
      ]
    }
  ]
}
%%{init: {'gantt': {'barHeight': 70, 'barGap': 8, 'topPadding': 50, 'leftPadding': 160, 'fontSize': 44}}}%%
gantt
  title One scene cycle (loop = true) · ms axis
  dateFormat x
  axisFormat %Lms

  section intro · sync
    FADE 2000ms            :0, 2000
    hold · waits barrier   :done, 2000, 5500

  section accent · sync
    WAVE 2000ms            :0, 2000
    hold · waits barrier   :done, 2000, 5500

  section main · startAfter intro
    waiting · dark         :done, 0, 2000
    GAP 500ms              :done, 2000, 2500
    BREATHE 3000ms         :2500, 5500

  section bg · async
    BLINK 1500ms           :active, 0, 1500
    BLINK 1500ms           :active, 1500, 3000
    BLINK 1500ms           :active, 3000, 4500
    BLINK · crosses barrier :active, 4500, 6000

  section barrier
    sync layers restart ↻  :milestone, 5500, 0
Layer Mechanism Behaviour
intro, accent Parallel (no startAfter) Both start at t=0. accent finishes at 2000 but holds until the barrier.
main startAfter: intro + gap Stays dark until intro ends (2000), waits a 500ms gap, then breathes.
barrier Phase-lock Fires at 5500 — the end of the longest dependency chain (intro → main), not the longest single layer. All sync layers reset together, so nothing drifts.
bg async: true Blinks every 1500ms, ignoring the barrier — note the blink crossing 5500 and continuing into the next cycle.

Appendix: Validation Rules

These apply to POST /api/scenes, PATCH /api/scenes/:id, and POST /api/scenes/play/one-shot. All violations return HTTP 422.

Field Rule
Scene name (display) 1–64 chars
Scene id (when present) 8–10 chars, lowercase [a-z0-9]
Layer count 1–8
Steps per layer 1–12
group Name (string) or number 1–254; unique within the scene
Step id Optional, [a-zA-Z0-9_-] (no :), unique within the layer's sequence (schemaVersion: 8+)
startAfter "group" or "group:stepId" (schemaVersion: 8+); must name an existing group (and step, if given); no self-reference, no dependency cycles
async Bool, "loop", or "free"; loops the layer independently of the barrier ("free" opts out entirely). No effect when startAfter is set
type + runner Mutually exclusive — cannot both be set
Step with neither type nor runner Treated as a gap (timed no-op); only duration is used
type value Must be a known animation type string
runner value WAVE, RIPPLE, CHASE, WHEEL, BOUNCE, RAIN, SPARKLE, or MATRIX
duration 0–65535 ms; 0 only on the last step of a layer
Color values Valid #RRGGBB hex, {r,g,b} each 0–255, {"palette":0-255}, or {"useColor":0-2}
params[i] 0–255
params length 0–4
Panel indices 1–254; unique panel addresses (panels are numbered from 1)
Panel list length 1–32 per layer
Layer palette override Layers with different effective palettes should not share any target panel (not validated at save time)
Infinite last step Holds forever; layer (or its last step, if targeted via startAfter: "group:stepId") can't be a startAfter target, and blocks the whole-scene loop

Panel targeting & directionality

Beyond "all" and explicit index arrays, a layer's "panels" accepts graph selectors, and runner steps accept a source for directionality. Full grammar, semantics, and worked examples live in Scene Authoring → Targeting panels and Scene Authoring → Directionality; a summary:

  • Targeting ("panels"): "all", [1,3,5], {"exclude":[2]} (v2, unchanged), plus graph selectors "root" / "leaves" / "branches" / "depth:1-2" / "subtree:N" / "neighbors:N" / "fraction:0-0.33" / "first:K" / "last:K" / "even" / "odd", and composition objects {"any":[…]} / {"all":[…]} / {"not":…}. A selector that matches no panel here skips the layer (or uses an optional sibling "fallback" selector).
  • Directionality (runner steps WAVE/RIPPLE/CHASE/BOUNCE/RAIN, and WHEEL's pivot): "source"root | leaves | panel:N (default root) sets the graph origin the effect emanates from; "reverse": true flips it. The legacy "originPanel" is accepted and maps to source:panel:N. SPARKLE has no directionality — source/reverse/directionality/angle are ignored.
  • Per-device config (resolved against, set via the Configuration API): the logical root re-centres depth/subtree/source:root. It is device-local and not part of the shared scene.