# LED Controller API This document covers: 1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, zones, presets, transport send, pattern OTA helpers, and related resources. 2. **LED driver JSON** — the compact **v1** message format. ESP-NOW traffic is wrapped in a **devices envelope** (`dv` map keyed by MAC) on the Pi ↔ bridge link (WebSocket or USB serial); drivers receive compact per-device bodies (≤250 bytes). **Wi-Fi** drivers still accept **single JSON text messages** over an outbound WebSocket (same logical fields). Default HTTP listen address: `0.0.0.0`. Port defaults to **80**; override with the **`PORT`** environment variable (see `pipenv run run`). **ESP-NOW bridge:** Set **`bridge_transport`** to **`wifi`** (default) or **`serial`**. Wi‑Fi mode uses **`bridge_ws_url`** (e.g. `ws://192.168.4.1/ws`) after joining the bridge AP; serial mode uses **`bridge_serial_port`** and **`bridge_serial_baudrate`** (default **921600**). Saved bridge profiles and connect helpers live under **`/settings/wifi/*`** (see below). Architecture: [espnow-architecture.md](espnow-architecture.md). **Wi-Fi drivers (optional):** **UDP** on port **8766** is the **discovery** channel: each driver’s JSON hello (**`device_name`**, **MAC**, optional **`type`**) **creates or updates** that device in **`db/device.json`** (keyed by MAC); the Pi echoes the datagram. After a valid hello with **`v`:** **`"1"`**, the Pi also opens an **outbound WebSocket** to that IP (**`wifi_driver_ws_port`**, default **80**; **`wifi_driver_ws_path`**, default **`/ws`**) for v1 commands; presets are not pushed automatically on connect (use **Send Presets** / profile apply). The Pi may send periodic UDP **hello** nudges to known Wi‑Fi device IPs when the WebSocket is down (**`wifi_driver_hello_interval_s`** in settings). All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted. --- ## UI behavior notes The main UI has two modes controlled by the mode toggle: - **Run mode**: optimized for operation (zone/preset selection and profile apply). - **Edit mode**: shows editing/management controls (zones, presets, patterns, colour palette, send presets, profile management actions, **Devices** registry for LED driver names/MACs, and related tools). Profiles are available in both modes, but behavior differs: - **Run mode**: profile **apply** only. - **Edit mode**: profile **create/clone/delete/apply**. `POST /presets/send` is wired to the **Send Presets** UI action, which is exposed in Edit mode. --- ## Session and scoping Several routes use **`@with_session`**: the server stores a **current profile** in the session (cookie). Endpoints that scope data to “the current profile” (notably **`/presets`**) only return or mutate presets whose `profile_id` matches that session value. Profiles are selected with **`POST /profiles//apply`**, which sets `current_profile` in the session. --- ## Static pages and assets | Method | Path | Description | |--------|------|-------------| | GET | `/` | Main UI (`templates/index.html`) | | GET | `/settings/page` | Standalone settings page (`templates/settings.html`) | | GET | `/favicon.ico` | Empty response (204) | | GET | `/static/` | Static files under `src/static/` | --- ## WebSocket: `/ws` Connect to **`ws://:/ws`**. - Send **JSON**: the object is forwarded through the **ESP-NOW bridge** as a **devices envelope** (or legacy MAC-prefixed / binary payload). Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination from settings is used. Example envelope: [msg.json](msg.json). - Send **non-JSON text**: forwarded as raw bytes with the default address. - On send failure, the server may reply with `{"error": "Send failed"}`. Wi-Fi devices are not targeted by `/ws` directly; use **`POST /presets/send`**, device routes, or **`POST /patterns//send`** as appropriate. --- ## HTTP API by resource Below, `` values are string identifiers used by the JSON stores. **Device** ids are **12-character lowercase hex MACs** (no colons); other resources typically use numeric string ids. ### Settings — `/settings` | Method | Path | Description | |--------|------|-------------| | GET | `/settings` | Full settings object (from `settings.json` / `Settings` model). | | PUT | `/settings/settings` | Merge keys into settings and save. Returns `{"message": "Settings updated successfully"}`. | | GET | `/settings/wifi/ap` | Saved Wi‑Fi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). | | POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (1–11). Persists AP-related settings. | | GET | `/settings/page` | Serves `templates/settings.html`. | ### Bridge — `/settings/wifi` Pi-side bridge configuration (ESP-NOW path to drivers). Mounted from `controllers/wifi_bridge.py`. | Method | Path | Description | |--------|------|-------------| | GET | `/settings/wifi/interfaces` | List Wi‑Fi interfaces via NetworkManager (`nmcli`). | | GET | `/settings/wifi/scan?device=` | Scan SSIDs on the given interface. | | GET | `/settings/wifi/bridges` | Bridge state: `bridge_transport`, `bridge_ws_url`, `bridge_connected`, `wifi_interface`, saved **`bridges`** profiles, serial port/baud. | | PUT | `/settings/wifi/bridges` | Merge bridge settings and/or replace the **`bridges`** profile list. | | DELETE | `/settings/wifi/bridges/` | Remove a saved bridge profile. | | POST | `/settings/wifi/bridges//connect` | Connect using a saved profile (`transport`: `wifi` or `serial`). | | POST | `/settings/wifi/connect` | Join a bridge AP and open its WebSocket. Body: `device`, `ssid`, optional `password`, `ap_ip` (default `192.168.4.1`), `ws_port`, `label`, `save_profile`. | | POST | `/settings/wifi/serial/connect` | Open the bridge over USB serial. Body: `port`, optional `baudrate`, `label`, `save_profile`. | ### Devices — `/devices` Registry in `db/device.json`: storage key **``** is the device **MAC** (12 lowercase hex characters, no colons). Each record includes: | Field | Description | |-------|-------------| | **`id`** | Same as the storage key (12-char hex MAC). | | **`name`** | Shown in the UI; matched when building zone **`select`** lists. | | **`type`** | `led` (only value today; extensible). | | **`transport`** | `espnow` (default) or `wifi`. | | **`address`** | For **`espnow`**: same as **`id`** (MAC). For **`wifi`**: IP or hostname used for outbound WebSocket / OTA. | | **`connected`** | Response-only on GET list/detail: always **`null`** today (ESP-NOW has no live session flag on the Pi). | | **`default_pattern`**, **`zones`** | Optional. Legacy **`tabs`** may still appear in old files and is migrated away on load. | Drivers also **self-register** on ESP-NOW **ANNOUNCE** (bridge uplink) or Wi‑Fi UDP hello; manual **`POST /devices`** is optional. | Method | Path | Description | |--------|------|-------------| | GET | `/devices` | Map of device id → device object (includes **`connected`**). | | GET | `/devices/` | One device, 404 if missing. | | POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`mac`** (required for Wi‑Fi when address is set), **`default_pattern`**, **`zones`**. Returns `{ "": { ... } }`, 201. | | PUT | `/devices/` | Partial update. **`name`** cannot be cleared. **`id`** in the body is ignored. **`type`** / **`transport`** validated; **`address`** normalised for the resulting transport. | | DELETE | `/devices/` | Remove device. | | POST | `/devices//identify` | ESP-NOW: sends a short red **blink** preset (`__identify`, 10 Hz) via the bridge, then **`off`** after ~2 s. Not persisted on the Pi. | | POST | `/groups//identify` | Same identify blink for every device in the group (broadcast envelope; drivers filter by group membership). | ### Profiles — `/profiles` | Method | Path | Description | |--------|------|-------------| | GET | `/profiles` | `{"profiles": {...}, "current_profile_id": ""}`. Ensures a default current profile when possible. | | GET | `/profiles/current` | `{"id": "...", "profile": {...}}` | | GET | `/profiles/` | Single profile. If `` is `current`, same as `/profiles/current`. | | POST | `/profiles` | Create profile. Body may include `name` and other fields. Optional `seed_dj_zone` (request-only) seeds a DJ zone + presets. New profiles always get a populated `default` zone. Returns `{ "": { ... } }` with status 201. | | POST | `/profiles//apply` | Sets session current profile to ``. | | POST | `/profiles//clone` | Clone profile (zones, palettes, presets). Body may include `name`. | | PUT | `/profiles/current` | Update the current profile (from session). | | PUT | `/profiles/` | Update profile by id. | | DELETE | `/profiles/` | Delete profile. | ### Presets — `/presets` Scoped to **current profile** in session (see above). | Method | Path | Description | |--------|------|-------------| | GET | `/presets` | Map of preset id → preset object for the current profile only. | | GET | `/presets/` | One preset, 404 if missing or wrong profile. | | POST | `/presets` | Create preset; server assigns id and sets `profile_id`. Body fields stored on the preset. Returns `{ "": { ... } }`, 201. | | PUT | `/presets/` | Update preset (must belong to current profile). | | DELETE | `/presets/` | Delete preset. | | POST | `/presets/send` | Push presets to the LED driver over the configured transport (see below). | **`POST /presets/send` body:** ```json { "preset_ids": ["1", "2"], "save": true, "default": "1", "destination_mac": "aabbccddeeff" } ``` - **`preset_ids`** (or **`ids`**): non-empty list of preset ids to include. - **`save`**: if true, the outgoing message includes `"save": true` so the driver may persist presets (default true). - **`default`**: optional preset id string; forwarded as top-level `"default"` in the driver message (startup selection on device). - **`destination_mac`** (or **`to`**): optional 12-character hex MAC for unicast; omitted uses the transport default (e.g. broadcast). Response on success includes `presets_sent`, `messages_sent` (chunking splits payloads so each JSON string stays ≤ 240 bytes). Stored preset records can include: - `colors`: resolved hex colours for editor/display. - `palette_refs`: optional array of palette indexes parallel to `colors`. If a slot contains an integer index, the colour is linked to the current profile palette at that index. ### Zones — `/zones` | Method | Path | Description | |--------|------|-------------| | GET | `/zones` | `zones` (map of zone id → zone object), `zone_order`, `current_zone_id`, `profile_id` for the session-backed profile. | | GET | `/zones/current` | Current zone from cookie/session. | | POST | `/zones` | Create zone; optional JSON `name`, `names`, `presets`; can append to current profile’s zone list. | | GET | `/zones/` | Zone JSON. | | PUT | `/zones/` | Update zone. | | DELETE | `/zones/` | Delete zone; can delete `current` to remove the active zone; updates profile zone list. | | POST | `/zones//set-current` | Sets `current_zone` cookie. | | POST | `/zones//clone` | Clone zone into current profile. | ### Palettes — `/palettes` | Method | Path | Description | |--------|------|-------------| | GET | `/palettes` | Map of id → colour list. | | GET | `/palettes/` | `{"colors": [...], "id": ""}` | | POST | `/palettes` | Body may include `colors`. Returns palette object with `id`, 201. | | PUT | `/palettes/` | Update colours (`name` ignored). | | DELETE | `/palettes/` | Delete palette. | ### Groups — `/groups` | Method | Path | Description | |--------|------|-------------| | GET | `/groups` | All groups. | | GET | `/groups/` | One group. | | POST | `/groups` | Create; optional `name` and fields. | | PUT | `/groups/` | Update. | | DELETE | `/groups/` | Delete. | ### Scenes — `/scenes` | Method | Path | Description | |--------|------|-------------| | GET | `/scenes` | All scenes. | | GET | `/scenes/` | One scene. | | POST | `/scenes` | Create (body JSON stored on scene). | | PUT | `/scenes/` | Update. | | DELETE | `/scenes/` | Delete. | ### Sequences — `/sequences` | Method | Path | Description | |--------|------|-------------| | GET | `/sequences` | All sequences. | | GET | `/sequences/` | One sequence. | | POST | `/sequences` | Create; may use `group_name`, `presets` in body. | | PUT | `/sequences/` | Update. | | DELETE | `/sequences/` | Delete. | ### Patterns — `/patterns` Pattern metadata lives in **`db/pattern.json`**; driver source files live under **`led-driver/src/patterns/`**. Several routes expose a **runtime map** (metadata merged with on-disk `.py` names so new files appear in menus). | Method | Path | Description | |--------|------|-------------| | GET | `/patterns` | Runtime pattern map (object keyed by pattern id). | | GET | `/patterns/definitions` | Same runtime map (intended for UI “definitions” clients). | | GET | `/patterns/ota/manifest` | JSON `{"files":[{"name":"blink.py","url":"http:///patterns/ota/file/blink.py"},...]}` for OTA pulls. Requires **`Host`** header. | | GET | `/patterns/ota/file/` | Raw **`.py`** source for one driver pattern (`name` must be a safe filename, e.g. `rainbow.py`). | | POST | `/patterns//send` | Push a **manifest** JSON line to **Wi-Fi** devices so they pull one pattern file over HTTP. Body may include **`device_id`** to target one device; otherwise all Wi-Fi devices with an **`address`** are tried. **``** may be with or without `.py`. | | POST | `/patterns/upload` | Body JSON: **`name`**, **`code`**, optional **`overwrite`** (default true). Writes **`led-driver/src/patterns/.py`**. | | POST | `/patterns/driver` | Body JSON: **`name`** (identifier), **`code`**, optional metadata (`min_delay`, `max_delay`, `max_colors`, `n1`…`n8`, **`overwrite`**). Creates/updates both the **`.py`** file and **`db/pattern.json`** via the Pattern model. | | GET | `/patterns/` | One pattern record from the Pattern model (metadata only). | | POST | `/patterns` | Create (`name`, optional `data`). | | PUT | `/patterns/` | Update. | | DELETE | `/patterns/` | Delete. | **Devices — pattern OTA push** | Method | Path | Description | |--------|------|-------------| | POST | `/devices//patterns/push` | Wi-Fi only. Asks the driver at **`address`** to pull pattern files from this server. Optional body **`manifest`**: either a **URL string** pointing at a manifest JSON document, or a **manifest object** (same shape as in driver messages). If omitted, a default manifest is built from the request **`Host`** header. | --- ## LED driver message format (transport / ESP-NOW / Wi-Fi) Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces per-device bodies; **`build_devices_envelope()`** (`src/util/bridge_envelope.py`) wraps them for the bridge WebSocket or USB serial link. Wi-Fi drivers accept the same logical body as a **single JSON text message** over the outbound WebSocket. ### Devices envelope (Pi → bridge) On the bridge link, traffic uses a top-level **`dv`** map (long name **`devices`** still accepted on receive): ```json { "v": "1", "dv": { "e8:f6:0a:16:ea:10": { "p": { "2": { "p": "on", "c": ["#FFFFFF"], "a": true } }, "s": ["2", 0], "g": ["5"], "sg": false, "sv": true } } } ``` See [espnow-architecture.md](espnow-architecture.md) for routing (`sg`, broadcast MAC `ff:ff:ff:ff:ff:ff`, and group filtering). ### Per-device body fields (inside `dv` or Wi-Fi WebSocket) Short wire keys are used on the bridge and over ESP-NOW (long names still accepted on receive): ```json { "v": "1", "p": { }, "s": ["preset_id", 0], "sv": true, "df": "preset_id", "b": 255, "g": ["5"], "sg": false } ``` | Short | Long | Meaning | |-------|------|---------| | `p` | `presets` | Map of preset id → preset object (see below). | | `s` | `select` | **`["preset_id"]`** or **`["preset_id", step]`** — routing is by MAC envelope / group membership, not by device name. | | `sv` | `save` | If true, driver may persist presets to flash. | | `df` | `default` | Startup default preset id. | | `g` | `groups` | Group ids for membership updates or broadcast filtering. | | `sg` | `set_groups` | If true, replace stored group list before applying the body. | - **`v`** (required): Must be `"1"` or the driver ignores the message. - **`b`**: Optional **global** brightness 0–255 (driver applies this in addition to per-preset brightness). ### Preset object (wire / driver keys) On the wire, presets use **short keys** (saves space in the ≤240-byte chunks): | Key | Meaning | Notes | |-----|---------|--------| | `p` | Pattern id | `off`, `on`, `blink`, `rainbow`, `pulse`, `transition`, `chase`, `circle` | | `c` | Colours | Array of `"#RRGGBB"` hex strings; converted to RGB on device | | `d` | Delay ms | Default 100 | | `b` | Preset brightness | 0–255; combined with global `b` on the device | | `a` | Auto | `true`: run continuously; `false`: one step/cycle per “beat” | | `n1`–`n6` | Pattern parameters | See below | The HTTP app’s **`POST /presets/send`** path builds this from stored presets via **`build_preset_dict()`** (long names like `pattern` / `colors` in the DB are translated to `p` / `c` / …). ### Pattern-specific parameters (`n1`–`n6`) #### Rainbow - **`n1`**: Step increment on the colour wheel per update (default 1). #### Pulse - **`n1`**: Attack (fade in) ms - **`n2`**: Hold ms - **`n3`**: Decay (fade out) ms - **`d`**: Off time between pulses ms #### Transition - **`d`**: Transition duration ms #### Chase - **`n1`**: LEDs with first colour - **`n2`**: LEDs with second colour - **`n3`**: Movement on even steps (may be negative) - **`n4`**: Movement on odd steps (may be negative) #### Circle - **`n1`**: Head speed (LEDs/s) - **`n2`**: Max length - **`n3`**: Tail speed (LEDs/s) - **`n4`**: Min length ### Select messages ```json { "select": { "device_name": ["preset_id"], "other_device": ["preset_id", 10] } } ``` - One element: select preset; step behavior follows driver rules (reset on `off`, etc.). - Two elements: explicit **step** for sync. ### Beat and sync behavior - Sending **`select`** again with the **same** preset name acts as a **beat** (advances manual patterns / restarts generators per driver logic). - Choosing **`off`** resets step as a sync point; then selecting a pattern aligns step 0 across devices unless a step is passed explicitly. ### Example (compact preset map) ```json { "v": "1", "save": true, "presets": { "1": { "name": "Red blink", "p": "blink", "c": ["#FF0000"], "d": 200, "b": 255, "a": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0 } }, "select": { "living-room": ["1"] } } ``` --- ## Processing summary (driver) 1. Reject if `v != "1"`. 2. Apply optional top-level **`b`** (global brightness). 3. For each entry in **`presets`**, normalize colours and upsert preset by id. 4. If this device’s **`name`** appears in **`select`**, run selection (optional step). 5. If **`default`** is set, store startup preset id. 6. If **`save`** is set, persist presets. --- ## Error handling (HTTP) Controllers typically return JSON with an **`error`** string and 4xx/5xx status codes. Invalid JSON bodies often yield `{"error": "Invalid JSON"}`. --- ## Notes - **Human-readable preset fields** (`pattern`, `colors`, `delay`, …) are fine in the **web app / database**; the **send path** converts them to **`p` / `c` / `d`** for the driver. - For a copy of the older long-key reference, see **`led-driver/docs/API.md`** in this repo (conceptually the same behavior; wire format prefers short keys).