Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
# Patternflow HTTP API Specification
**Spec version: 1.5 (unreleased additions)** · the released 1.4 contract applies to firmware **≥ 3.5.1**, with later endpoints dated in the version history below. Fields and routes marked 1.5 require a build containing the unreleased changes.
Clients should tolerate unknown status fields and probe capabilities before calling edition-specific routes.
Patternflow serves a plain HTTP server on port 80 over the local Wi-Fi network. It carries two different things: the **device console** — HTML pages a person opens in a browser — and a **JSON API** under `/api/`, which is the contract between the firmware and any host software that drives a device over the network. Anything that drives a panel — a bridge, a card, a script — is built against this file, not against the firmware source.
`docs/osc-spec.md` is the sibling contract for OSC over UDP, aimed at DAWs and show software, and `docs/midi-spec.md` the one for MIDI. [`docs/mqtt-spec.md`](mqtt-spec.md) is the one for MQTT. The four are not interchangeable — see [Choosing a transport](#choosing-a-transport).
## Transport
| | |
|---|---|
| Base URL | `http://patternflow.local/` — mDNS, hostname from `PF_OTA_HOSTNAME`. The panel also answers to **`http://patternflow/`** over NetBIOS (Windows, no mDNS needed) and to a per-panel alias **`patternflow-<4 hex>.local`** (reported as `hostAlias` in `/api/status`, so two panels on one network are addressable apart). The raw IP works too and is the last resort on clients with no name resolution (Android). Every link from the console to patternflow.work carries `?device=<ip>`; the site remembers it, so its "send to my panel" buttons stop depending on any name. |
| Port | **80** for everything documented here. One server carries the console, the API and `/update`. A build with the audio feature adds a WebSocket on **81** (`PF_AUDIO_WS_PORT`) for streaming audio clients — its wire protocol is [`audio-ws-spec.md`](audio-ws-spec.md). |
| Advertised over mDNS | `_http._tcp` on port 80 and `_arduino._tcp` when `PF_OTA_ENABLED`. In the 1.5 implementation `core_names.h` owns startup and retry; in released 1.4 these are registered by the web updater and ArduinoOTA. HTTP carries no TXT records and OTA carries the Arduino discovery fields (board type, auth flags), with nothing Patternflow-specific. A discovering client must probe `GET /api/status` to confirm what it found. |
| Concurrency | **One connection.** See [Rules that will bite you](#rules-that-will-bite-you). |
| Authentication | **None.** No token, no password, no session. Anyone on the LAN can call anything here, including `POST /update`. This is a deliberate trust model, the same one ArduinoOTA's empty-password default has; `PF_WEBUPDATE_ALWAYS_ARMED 0` is the one lever that narrows it. |
| CORS | Only `/api/patterns/select` and `/api/display` send `Access-Control-Allow-Origin: *`. Everything else is same-origin only, so a browser page on another origin cannot read it. Server-side clients are unaffected. |
| Cache | Every JSON endpoint sends `Cache-Control: no-store`, and so does the plain-text `404`. |
| Page caching | Since 1.5. A console page is versioned by its URL: the console links every page as `/path?v=<build>`, `build` being the running image's id from [`/api/status`](#get-apistatus). `?v=` naming the running build is served `Cache-Control: public, max-age=31536000, immutable`, so a browser keeps the page until the firmware changes and does not ask again. No `v` (a typed URL, a bookmark, a `?src=` handoff) is served `no-cache`: the browser keeps it but asks each time, and gets a bodiless `304` while the page is unchanged. A `v` naming another build is answered `302` (on a panel `Host`; see Host gating) to the same path with `v` set to the running build and every other query argument kept (`Cache-Control: no-store`). Pages carry a weak `ETag` (`W/"<crc32>-<length>"` of the uncompressed page), and a `304` carries the same `ETag`, `Cache-Control`, `Content-Type` and the `Content-Length` a `200` would have. Byte ranges are not served. |
| Console chrome | `/pf-console.js`, the header script every page loads, is keyed by its own content instead: pages ask for `/pf-console.js?h=<crc32>`, which is immutable when `h` is the script's CRC-32, `no-cache` with an `ETag` when `h` is absent, and the current script with `no-store` when `h` names another one. |
| Host gating | Pages, the chrome and the favicon are cacheable only when the request's `Host` can only be the panel: an IPv4 address, a name with no dot (`patternflow`) or a `.local` name, with or without a port. Any other `Host` (a router-assigned name such as `patternflow.fritz.box` included), or none, gets `no-store`, no `ETag`, never a `304` and never a `302`: a page asked for with a stale `v` is served as it is now, and the console's own version check moves it to the running build's `v` once its status arrives (or offers to, while the page is in use). On the hotspot every name resolves to the panel, and a phone must not keep the console under the origin of a site it was trying to reach. |
| Favicon | `GET /favicon.ico` answers `204`, with `Cache-Control: public, max-age=31536000, immutable` on a panel `Host` and `no-store` on any other (see Host gating); pages carry their icon inline. |
| Encoding | Responses are `application/json`, hand-assembled in the firmware. Request parameters are **query-string or form-encoded, never a JSON body** — `POST /api/sleep?on=1` and a form body with `on=1` are the same call. The console **pages** (`/`, `/status`, `/patterns`, `/wifi`, `/update`, the feature pages, `/pf-console.js` and the unzip library the patterns page fetches) are sent with `Content-Encoding: gzip` since 1.4, regardless of `Accept-Encoding` — a browser decodes that without being asked; a script fetching a page wants `curl --compressed` or its equivalent. Nothing under `/api/` is compressed. |
## Rules that will bite you
Three properties of this server are not obvious from the endpoint list, and each one has already cost a feature.
**One connection at a time.** The vendored `WebServer` accepts a single client. Until 3.9.1 the render loop paid for every response — the panel was not being drawn while one was sent; since 3.9.1 the server is serviced by a task on the other core, and a page load no longer costs a frame. The connection rule stands. A device-streamed frame preview (`GET /api/frame`, 24 KB per poll) was built, shipped and removed the same day: polling it while a pattern module was resident captured the render loop for seconds at a time and piled requests up until the device read as dead. `/remote` and `/api/knob` were removed alongside it as unused. **Issue requests strictly sequentially**, and treat a poll interval under a second as a bug rather than a feature. The lesson that came out of that day is written into the firmware as a design rule: a live pattern preview renders in the client from the pattern's own JS — *the device never streams pixels*.
**Opening an HTML console page no longer pauses the pattern** (since 3.6.3). It used to, and the old rule is worth knowing because the flag it left behind is still in the payload: on the core-3 builds a resident `.pfm` and a page render could not share the ~15 KB of post-services DRAM, so every console page evicted the module and held it out for 25 s of idle. The PlatformIO core-2 builds freed ~96 KB, and the page sender streams each page out of flash a TCP segment at a time — gzip-compressed since 1.4, so about a third of the bytes cross the air, and no longer under the hard 5-second budget that used to cut a 26 KB page short on a slow Wi-Fi link while the header promised all of it (the budget still applies when `loop()` itself is serving, `httpCore: 1`; on the network task a send is abandoned only when the peer stops accepting bytes for ~20 s, and then the connection is closed rather than left looking complete). So `GET /`, `/status`, `/patterns`, `/wifi`, `/mqtt`, `/show` and `/weather` all serve with the pattern running.
Two things stayed true. **Installing patterns still evicts the running module for the duration of the upload batch** — the panel shows PAUSED and restores itself — which is what `status.consolePaused` reports now; treat it as "an install is in progress", not "somebody looked at the console". And a page load still momentarily competes with the render loop on this one-connection server, so sending a person to `http://patternflow.local/` is fine and expected; **polling HTML pages remains a bug** — automated clients keep to `/api/*`.
**Some writes report the state before they take effect.** Stopping a DMA engine, reclocking the CPU, reading FATFS and running the ELF relocator are not things a handler does inside an open HTTP transaction — the firmware queues them and `loop()` performs them. `POST /api/sleep` and `GET /api/patterns/select` therefore return the *previous* state. Set your own state optimistically and let the next poll confirm it; do not parse the reply's state field as the new value. (Since 3.9.1 the same rule holds from the other side: handlers run on the network core, and the few that must touch what the frame is using are executed on the loop task at the frame boundary — `loopSyncServed` in status counts them.)
## Device state
### `GET /api/status`
The numbers that explain a device when something is off. Requires `PF_STATUS_HTTP_ENABLED` (default on).
```json
{
"version": "3.5.1", "build": "3f9a0c1e", "uptime": 4213, "resetReason": "poweron", "panel": "128x64",
"wifi": true, "ssid": "studio", "ip": "192.168.1.42", "rssi": -54,
"host": "patternflow", "viaHotspot": false,
"heapInternal": 11052, "heapLargest": 8192, "heapPsram": 4194304,
"fsMounted": true, "fsTotal": 6291456, "fsUsed": 204800, "fsError": "",
"flashId": "c84018",
"patterns": 34, "presets": 1, "modules": 33,
"active": "Wave Saw", "activeIsModule": true,
"sleep": false,
"knobs": [12, 0, -3, 40], "params": [500, 500, 750, 500],
"paramActive": [false, false, true, false],
"lanes": [0.0, 0.0, 0.0, 0.0], "laneActive": [false, false, false, false],
"consolePaused": false, "busy": "",
"frameUs": 16400, "presentUs": 3100, "loopCore": 1,
"httpCore": 0, "netStackMin": 6200, "loopSyncServed": 3, "loopSyncMaxUs": 9800,
"colorBits": 6, "refreshHz": 121,
"loadError": "", "load": { "total": 0, "read": 0, "relocate": 0, "setup": 0, "internal": 0, "psram": 0 },
"mqttRole": "off", "mqttState": "idle", "mqttConnected": false
}
```
| Field | Meaning |
|---|---|
| `version` | Firmware version string (`PF_IMPROV_FW_VERSION`). |
| `build` | Which image exactly, as eight lowercase hex digits: the start of the firmware ELF's SHA-256 (a hash of the compile time and version on an image built without it). Two images with the same `version` still differ here. Console page URLs carry it as `?v=` (see [Page caching](#transport)); a page open under another build is stale. Since 1.5. |
| `uptime` | Seconds since boot. |
| `resetReason` | Why the board is running, `esp_reset_reason()` by name: `"poweron"` (the plug), `"sw"` (the reboot button, a finished update), `"panic"`, `"task_wdt"`, `"int_wdt"`, `"wdt"`, `"brownout"`, `"deepsleep"`, `"ext"`, `"sdio"` or `"unknown"`. Read it together with `uptime`: a small uptime and anything but `"poweron"` or `"sw"` means the board restarted on its own — which from the network otherwise looks exactly like a power cut. The same word is printed once on serial at boot (`[BOOT] reset reason: …`). Since 1.4. |
| `panel` | Physical matrix, `"<w>x<h>"`. The closest thing to a model number. |
| `host` | mDNS hostname, i.e. `PF_OTA_HOSTNAME`. **Not unique** — every device ships as `"patternflow"`. See [Identifying a device](#identifying-a-device). |
| `network` | Since 1.5, when Wi-Fi is compiled in: `disconnects` counts sampled connected→disconnected edges; `retries` counts explicit retry attempts (including initial join failures); `reconnectMs` is the last sampled downtime, zero before a reconnect. `namesReady` means the core's local mDNS/service/alias and NetBIOS registrations succeeded; probing may still be in progress and it does not prove client reachability. `announcements` counts successful registration passes. Counters reset on boot. |
| `hotspot` | Since 1.5. The panel's own access point: `mode` (`off`, `auto`, `always`), `up`, `ssid` (the board alias, `patternflow-a1b2`), `ip` (`192.168.4.1` while up, else `""`), `channel`, `clients`, and `dns` (name queries answered on the hotspot since boot). |
| `viaHotspot` | Since 1.5. Whether *this* request arrived over the panel's own access point: the client is one phone on a link the panel carries itself, and the console fetches nothing ahead of time there. |
| `heapInternal` | Free internal DRAM. The scarce one: HUB75's DMA buffers live there, and below roughly 10 KB the console starts answering with headers and no body while Wi-Fi and OSC carry on looking healthy. Worth watching. |
| `fsError` | Why the pattern storage is not mounted, in words; `""` while it is mounted, and before any mount has been tried. Since 1.5. The core's serial line says `Mounting FFat partition failed! Error: -1` whatever the cause, so the first failure in a boot reads the volume's boot sector and records what it found. `"not formatted"` is a freshly erased board — press Format on `/patterns`. `"format did not stick: …"` means a Format ran and reported success, but the flash did not keep what was written. `"no filesystem (boot sector …)"` shows the sector's first and last bytes. `"boot sector looks valid but does not mount"` means the data is on the chip and the read side refused it. A failed `POST /api/patterns/format` returns the same text as its `error`. |
| `flashId` | The flash chip's JEDEC id as the driver detected it at boot, six hex digits: manufacturer, then device. `"c22018"` is a 16 MB Macronix part. Since 1.5. |
| `active` | **Display name** of the running pattern, or `"-"`. Not a slug and not an index. |
| `activeIsModule` | `true` for an uploaded `.pfm`, `false` for a preset compiled into `firmware.bin`. |
| `sleep` | Panel off / board idling. A sleeping device answers every other field here looking perfectly healthy. |
| `brightness` | Panel brightness as set, 5–255. K1's long-press session and `GET /api/display?brightness=` move the same value; what the power clamp lets through is `power_applied` on `/api/display`. Since 1.4. |
| `knobs` | The four encoders' absolute accumulated click counts — the same numbers the running pattern sees. Signed, unbounded, and meaningful only as a difference: there is no scale and no zero. |
| `params` | The absolute parameter bus, 0..1000 per lane. What `POST /api/params`, OSC and MQTT all write to. |
| `paramActive` | Per lane: is a remote writer currently holding it? Goes `false` a beat after somebody turns that encoder, because a hand in the room outranks the network. |
| `lanes` / `laneActive` | The continuous 0..1 lane per knob and whether something is driving it — the audio WebSocket, the on-board microphone, weather. Sits between the absolute bus and the encoders in priority; see [`audio-ws-spec.md`](audio-ws-spec.md) for the semantics. |
| `consolePaused` | A pattern-install batch is in progress and the module is evicted. Not an error. (Console pages stopped pausing the pattern in 3.6.3 — the name is older than that.) |
| `busy` | Since 1.5. What is keeping the one connection occupied, as a word to slow polling for: `"update"` while a firmware image is arriving or the reboot after it is due, `"storage"` while the pattern volume is being written (a format, a delete, or an install batch between files), `""` otherwise. The console polls no faster than every 5 s while it is non-empty. |
| `frameUs` / `presentUs` | Smoothed rendered-frame time and the part spent pushing pixels. `1e6 / frameUs` estimates rendering fps; it excludes housekeeping and is not an input-latency or worst-loop-gap measurement. |
| `runtime` | Since 1.5. Whole-loop `loops`, `lastUs`, `maxUs`, `over50ms`; `housekeepingMaxUs` includes work before the rendered-frame timer, and `syncMaxUs` measures execution of a frame-boundary request, excluding time its caller waits. Timing includes loading-state pacing, but excludes time between exiting one loop and entering the next. `GET /api/status?resetTiming=1` returns the current runtime snapshot and starts a new timing window; a loop spanning the reset is discarded. This resets only `runtime`, not lifetime network/load counters. |
| `moduleMemory` | Since 1.5. `reserve` is the module allocator's internal byte-addressable RAM floor, not a guarantee about allocations by other subsystems. `serviceFree` is current internal byte-addressable free RAM; `execLargest` is the largest contiguous executable block. `runtimeBytes` and lifetime `runtimePeakBytes` track retained module dynamic allocations; `runtimeLimit` defaults to 4 MiB. Sections and temporary ELF storage are admitted separately. `refusals` counts allocation refusals, including recovered attempts; `loaderRetries` counts the asynchronous loader's bounded retries after such failures. `loaderStackBytes` reports the reusable worker's reserved stack, and `loaderStackMin` is its minimum free space after jobs (0 until measured). |
| `netMaintenance` | Since 1.5. Lifetime `calls` and `maxGapMs` for the Wi-Fi/name maintenance hook on the network worker. It runs approximately every 25 ms when serviced, including page-body sends and parser/loop-request waits. Other blocking work, including filesystem calls, can still produce longer gaps. |
| `loopCore` / `httpCore` | Which core renders (1) and which answered this request — `0` on 3.9.1 and later, `1` when the network task could not be created and `loop()` is serving. |
| `netStackMin` | The network task's stack high-water mark in bytes: the least it has ever had free. Falling toward zero is the thing to watch after a firmware update. |
| `loopSyncServed` / `loopSyncMaxUs` | How many handlers had to run on the loop task (a module eviction, a show start, an MQTT reconnect) and the longest one waited for a frame boundary. |
| `colorBits` / `refreshHz` | What the HUB75 driver actually settled on — it trades colour depth against the requested refresh rate, so these are read back rather than configured. |
| `loadError` | Why the last module load failed, empty when it did not. Without it a refusal is invisible from the network. |
| `loading` | `true` while a module is being loaded on the network core (since 1.4 a switch no longer holds the frame; a heavy pattern's `setup()` takes seconds). `active` reads `"-"` meanwhile. |
| `thumbs` | Since 1.5: boot counters `captures` (cache refreshed), `reads` (disk image adopted), `writes` (save completed). `captureMaxUs` is the maximum loop-side capture duration; `ioMaxUs` is the maximum background file-operation wall time, including scheduling/filesystem contention. Both are µs. The cache and its temporary I/O snapshot use PSRAM only. |
| `load` | The last module load, in µs: `total`, `read` (flash), `relocate`, `setup` (the pattern's own). Since 1.4 also where its sections landed, in bytes: `internal` and `psram`. A big `internal` is the number to look at when the console dies the moment a pattern is chosen — the loader now sends data sections over 16 KB, or any that would leave under 24 KB of internal heap, to PSRAM (`PF_MODULE_DATA_INTERNAL_MAX`, `PF_MODULE_INTERNAL_RESERVE`). |
| `variant` | Which firmware this is: `"core"`, or a variant's own name. What the site's variant list matches, and what stops the update banner offering a core build on top of someone's chosen firmware. |
| `caps` | What this build can do. **Probe this rather than assuming a feature exists.** The default build reports `["patterns","params","sleep"]` and nothing else; each [edition](EDITIONS.md) adds its own — Audio adds `osc` and `audio`, Performance adds `weather`, `mqtt` and `shows`. `patterns` and `params` are on every build. |
| `mqttRole` | `"off"`, `"publisher"` or `"subscriber"`. Decides whether the device obeys knob and pattern topics — see [Knobs](#knobs-and-parameters). |
| `featureNav` | `[path, label, one-line description]` per console page the loaded features serve, e.g. `[["/audio-in","Audio","The panel hears the room…"]]`. What the console header and home screen build their feature links from; empty on the default build. |
### `POST /api/params`
Write the absolute parameter bus — the four channels a pattern reads as
set-points beside the physical encoders.
`p1`..`p4`, each `0..1000`, any subset; at least one required. A channel is
**held** once written and released the moment somebody turns that knob, so
an automated source can pin a look and hands always win it back. Patterns
see it only if they declare `ABSOLUTE_READY` and were built at module ABI 2.
curl -X POST http://patternflow.local/api/params -d "p1=750&p3=250"
→ {"ok":true,"params":[750,500,250,500],"active":[true,false,true,false]}
**One request in flight, by contract.** This server takes a single
connection, so a slider keeps one request in flight and sends the newest
value when it returns — what the console's own sliders do — rather than
streaming a value per pixel of travel.
`d1`..`d4` add **relative clicks** to a lane instead, for a client that has
only those (an encoder of its own). A legacy pattern — one that reads
`knobDeltas` and nothing else — follows a held value too: the device turns
the change in it into clicks, ten bus units per click, so `p1=750` moves
such a pattern the same way the encoder would. Do not send `pN` and `dN` for
the same move; that is the same clicks twice.
This is the plain-HTTP door to a capability that used to require an MQTT
broker; it exists so that MQTT can live in a variant without taking remote
knob control out of the core with it.
### `POST /api/sleep`
Panel off, board idling, **still on the network**. Not `esp_deep_sleep`: that would take the draw to microamps and the radio with it, and then "lights out from the sofa" has no way back.
| Parameter | Values |
|---|---|
| `on` | `1` / `true` / `sleep` · `0` / `false` / `wake` · `toggle`. Default `1` when omitted. |
```json
{ "ok": true, "requested": true, "sleep": false }
```
`sleep` is the state **before** the transition — the request is only queued here. `400` with `{"ok":false,"error":"on must be 1, 0, or toggle"}` for anything else.
Any physical knob turn or button press wakes the device, as does an incoming firmware image. A knob message arriving over OSC or MQTT deliberately does **not**: a show still streaming knob values at a sleeping panel should not switch the lights back on.
### `GET /api/display`
Runtime display calibration, power telemetry, and a read-only mirror of `sleep`. Requires `PF_STATUS_HTTP_ENABLED && PF_DISPLAY_HTTP_ENABLED`. `GET` on purpose rather than `POST`: it is meant to be tunable from a browser address bar, and the local tuning page fires simple no-preflight requests at it. Sends CORS `*`.
Any subset of these may be set in the query string; the response always reports the full state afterwards.
| Parameter | Range | |
|---|---|---|
| `wb_r` `wb_g` `wb_b` | 0 – 1.5 | White balance per channel |
| `gamma_r` `gamma_g` `gamma_b` | 0.2 – 5 | Gamma per channel |
| `sat` | 0 – 4 | Saturation boost |
| `brightness` | 5 – 255 | Panel brightness, the value K1's long-press session sets. Applied through the power clamp on the next frame; written to NVS once it has been still for 3 s. Since 1.4. |
| `screen` | 0 – 3, or `-1` | Summons the calibration test card over the running pattern; `-1` dismisses it |
| `level` | 8 – 255 | Drive level of the WHITE test screen |
| `power_budget` | mA | Total power clamp budget |
| `power_limit` | `0` / `1` | Enable the clamp |
Read-only in the response: `calib`, `sleep`, `power_ma`, `power_demand`, `power_limiting`, `power_applied`.
Colour values are session-only; a reboot restores the `config.h` defaults. The point is the tuning loop — put a test card on the panel, drag sliders until it looks right, then copy the converged numbers into `config.h` as the new shipped defaults.
## Patterns
Requires `PF_PATTERNS_HTTP_ENABLED` (default on).
A device's pattern list has two halves. **Presets** are compiled into `firmware.bin`; only `Origin` ships as one now, as the failsafe that runs even when the filesystem will not mount. **Modules** are `.pfm` files on the FATFS partition, discovered at boot and appended after the preset — a ~6 KB upload instead of a 1 MB reflash. Presets are listed but never deletable.
### `GET /api/patterns`
```json
{
"active": 3, "presets": 1, "mounted": true, "free": 6086656,
"patterns": [
{ "index": 0, "name": "Origin", "module": null },
{ "index": 1, "name": "Wave Saw", "module": "wave_saw" }
],
"pendingRev": 0, "pending": []
}
```
`active` is an index into `patterns`, or `-1` while nothing is loaded. `module` is the slug for an uploaded module and `null` for a preset. `pending` / `pendingRev` are the slugs a Performance Director has marked for ZIP export over MQTT — ignore them unless you are that tool.
**Indices are not stable.** Installing or deleting a single `.pfm` renumbers everything after it, which is why the firmware persists the running selection as a slug rather than an index. Re-read this endpoint after any upload or delete.
**Display names are not unique either.** Two modules may legitimately carry the same `name` in their sidecar. Address a pattern by `index` and disambiguate by `module` slug; `?name=` exists but stops at the first match.
### `GET /api/patterns/select`
| Parameter | |
|---|---|
| `index` | Registry index, 0 – `patterns-1` |
| `name` | Display name, exact match. Only if you have no index. |
| `step` | `+1` or `-1`: the next or previous pattern that is not hidden, wrapping, from wherever the panel is now. What the home page's `←` / `→` send. |
```json
{ "ok": true, "index": 3, "name": "Firefly Hollow" }
```
Queued and applied in `loop()`, for the same reason as sleep: activating a module reads FATFS and runs the relocator. `404` for an out-of-range index or an unknown name. Sends CORS `*`.
An explicit pick here supersedes a pending console restore — without that, a pattern chosen over the network ran until the console went idle and then snapped back to whatever had been playing before.
### `GET /api/patterns/file`
| Parameter | |
|---|---|
| `slug` | Module slug. Sanitised the same way uploads are. |
| `ext` | `pfm` (default), `json`, or `thumb` (since 1.4) |
`ext=thumb` returns the panel's own picture of the pattern: the 128×64 frame it last drew, taken off the canvas when the pattern was left after running for a couple of seconds and kept beside the module as `/patterns/<slug>.thumb`. Eight bytes of header (`PFT1`, width and height as little-endian u16) then width×height RGB565 pixels, 16,392 bytes for this panel. `404` until the pattern has been played once; presets (`origin`) get one the same way. The device draws these while browsing with K4 — see the firmware README on SELECT mode — and a console or site can show them for the same reason.
`ext=json` returns the module's **sidecar** — the only place per-pattern metadata is exposed over HTTP:
```json
{
"name": "Layer Stack", "namespace": "LabStack_Layer_Stack",
"author": "unknown", "license": "CC-BY-SA-4.0",
"abi": 2, "knobs": ["Waves", "Speed", "Sun", "Glitter"],
"slug": "layer_stack", "absoluteReady": true,
"panel_w": 128, "panel_h": 64, "module": "layer_stack.pfm",
"size": 6144, "opt": "-Os"
}
```
`knobs` is the four knob labels in **logical** order (see [Knob ordering](#knob-ordering)). `absoluteReady` says whether the module was built against the absolute parameter helpers, which decides how it can be driven remotely. A missing sidecar or a missing key both mean "no".
Presets have no sidecar. Their knob labels and their `ABSOLUTE_READY` flag live in the C++ `PatternEntry` and are **not** readable over HTTP — a client should fall back to `K1`–`K4` and assume `absoluteReady: false`.
`404` if the file is not there, `409` if storage is not mounted. This is a file download (`Content-Disposition: attachment`), one file at a time — the server is single-connection.
### Installing and removing modules
| Route | |
|---|---|
| `POST /api/patterns` | Multipart upload of one `.pfm` / `.json`. Kept for `curl` and older pages. |
| `PUT /api/patterns` | **Preferred.** Raw body; filename in the `X-PF-Name` header. |
| `DELETE /api/patterns?slug=…` | Remove one module and its sidecar. |
| `POST /api/patterns/delete` | Body is one slug per line, or a single `*` for all. `{"ok":true,"removed":n,"missing":n}` |
| `POST /api/patterns/format` | Formats the FATFS volume. Destroys modules **and** `.pfv` clips. |
Raw `PUT` is what the device's own page uses: this `WebServer`'s multipart parser is the flakiest part of the stack, and a raw octet stream sidesteps all of its boundary handling.
Batching matters. A rescan-and-reload after every file is what made installing a pack of four fail on the fourth: a resident module owns 5–8 KB of internal DRAM, which is exactly what parsing an upload body needs. Mark every file but the last with `last=0` (form field) or `X-PF-Last: 0` (header) and the reload runs once per batch. Sending neither is treated as a batch of one.
A successful reply means the bytes arrived **and** the file re-read as structurally a module — a truncated upload is rejected here rather than revealing itself when somebody turns the knob to it.
```json
{ "ok": true, "slug": "firefly_hollow", "bytes": 6144, "patterns": 34 }
```
Uploading evicts the running module for the duration of the batch, so the panel shows a PAUSED screen and comes back afterwards. An abandoned batch restores itself after 5 s.
## Knobs and parameters
Both directions work over HTTP. This section used to say the opposite, and it was true until `POST /api/params` landed.
**Reading.** `GET /api/status` reports `knobs` (absolute accumulated clicks, the same numbers a pattern sees), `params` (the absolute bus, 0..1000) and `paramActive` (whether each lane is currently held by a remote writer rather than a hand). This is core and is always present.
`GET /api/mqtt` reports the same three fields and has since before `/api/status` did — but it lives in the MQTT addon, so a build without MQTT does not serve it. **Read knob state from `/api/status`**, not from `/api/mqtt`, unless you specifically want the broker fields alongside it.
**Writing.** `POST /api/params` sets any subset of the four lanes, 0..1000, and is core — see above. A hand on the encoder releases the lane it touches, so a remote value never fights a person. `/patternflow/knob/N/delta` over OSC (`docs/osc-spec.md`) and `<prefix>/knob/N` / `<prefix>/param/N` over MQTT do the same job for clients already speaking those.
`/api/knob` and `/remote` existed and were removed as unused alongside the `/api/frame` incident; do not look for them.
### `GET /api/knobs` and `POST /api/knobs`
Since 1.5. Which way each encoder counts and how many edges make one click —
properties of the part soldered in, which is why they are settings rather than a
rebuild. `GET` returns `inv` (four booleans: reversed or not), `sub` (edges per
click per knob: 4, 2 or 1 — most encoders are 4), `clicks` (each knob's click
count as the frame sees it) and `raw` (the edge counters underneath, for telling a
2-edge part from a 4-edge one). `POST` takes any subset of `inv0`..`inv3` (`0`/`1`)
and `sub0`..`sub3` (`4`/`2`/`1`), or `inv=` / `sub=` for all four, form-encoded;
a change is applied at a frame boundary without moving the knob, stored on the
device (NVS `pf_knobs`) and answered as `GET`. The `/knobs` page is this endpoint
with a live readout. `INVERT_ENCODER` in `config.h` is only the compile-time
default a fresh device starts from.
### `GET /api/mqtt`
```json
{
"ok": true,
"role": "subscriber", "channel": "broadcast", "state": "connected",
"host": "192.168.1.10", "port": 1883, "user": "ha", "prefix": "patternflow",
"pattern": "Wave Saw", "error": "", "mode": "normal",
"connected": true, "configured": true, "hasPassword": true, "forcesSub": false,
"knobs": [128, -4, 0, 96],
"params": [500, 500, 500, 500],
"paramActive": [true, false, false, false]
}
```
| Field | |
|---|---|
| `role` | `"off"` / `"publisher"` / `"subscriber"` — which topics the device obeys |
| `prefix` | Topic prefix. Read it here rather than asking the user twice. |
| `forcesSub` | The selected channel (1–4) pins the role to Subscriber |
| `knobs` | Absolute accumulated click counts, signed, unbounded. Live in every role. |
| `params` | Absolute parameter bus, 0–1000 per channel |
| `paramActive` | Whether that channel is currently *held* at its absolute value |
Also reported: `normalHost`, `normalPort`, `normalUser`, `normalPrefix`, `normalHasPassword` (the saved Normal-mode broker, kept while Director mode overlays it) and `directorHost`. Passwords are never returned — only whether one is set.
### Which topic to write, and which channel
Moved to [`mqtt-spec.md`](mqtt-spec.md): the `absoluteReady` rule that decides between `param/<n>` and `knob/<n>`, which topics each role obeys, and why an external controller on channels 1–4 or Live sees its writes overwritten by the retained snapshot (Broadcast + Subscriber is the combination that behaves for one panel and one controller).
### Knob ordering
Two orderings exist and they are not the same.
- **Physical**: K1–K4 left to right on the front panel. This is what an encoder turn produces and what a person means.
- **Logical**: the order patterns read parameters in (hue, speed, mode, freq/offset). `knobs[]` in a sidecar, the OSC and MQTT indices, and `InputFrame` are all logical.
The mapping lives in `web/src/lib/pattern/controls.ts` as `LOGICAL_KNOB_TO_WEB_KNOB = ['c1','c2','c4','c3']` — note that K3 and K4 swap. Convert once, at the boundary of your client, and say in a comment which side of it you are on.
Scale constants live in the same file and are the contract for anything converting between a parameter value and encoder detents: `ENCODER_CLICKS_PER_TURN = 24`, `TURNS_PER_FULL_RANGE = 2` — so 48 detents cross a parameter's entire declared range, whatever that range is.
## Microphone (Audio edition)
Gated on `"audio-in"` in `caps`; the page is `/audio-in`. Settings persist in
NVS. The live shaping UI polls the same endpoints documented here — there is
nothing it can do that a script cannot.
| Endpoint | Meaning |
|---|---|
| `GET /api/audio-in` | Full state: `micOn`, `micGain` (input gain, 1..16, applied to mic samples before analysis - raw fields stay unscaled), `autoRange` (each band self-normalizes against its tracked floor/peak envelope; on by default), `source` (`"off"`, `"pdm"`, `"pdm (no mic - data pin idle)"`, `"synth"`), raw `rawPeak`/`rawDc`, per-band config (`hzMin`/`hzMax`/`inMin`/`inMax`/`gain`/`outMin`/`outMax`/`knob`/`muted`), `hzRange`, and a 40-bucket log `spec`. |
| `GET /api/audio-in?levels=1` | The polling shape: `levels`, `levelsN` (the level normalized inside the band's tracked range - what the mapping consumes in auto mode), `outputs`, `spectrum`, `dropped` and nothing configurable — the page owns the config after reading it once. |
| `POST /api/audio-in` | `mic=0/1` switches the whole feature (installs/releases the I2S driver). `micGain=1..16` sets the input gain; `auto=0/1` switches per-band auto-ranging. `band=N` plus any subset of the band fields updates one band; partial updates are the point, so a dragged handle cannot clobber the other three. |
| `POST /api/audio-in/reset` | Back to the measured defaults. |
`micDropped` in `/api/status`'s `audioIn` block counts capture hops discarded
because analysis fell behind; flat is healthy, climbing means overload.
### Audio-React (Audio edition)
Gated on `"audio"` in `caps`. The switch that lets the browser extension and
the phone capture drive the knobs — the same one the panel's NETWORK screen
toggles — from the console's `/audio-in` bar.
| Endpoint | Meaning |
|---|---|
| `GET /api/audio` | `audioRuntime` (the switch) and `audioClients` (WebSocket senders connected on :81). |
| `POST /api/audio` | `on=0/1`. Persisted in NVS; answers with the new state. |
## Clock (Clock edition)
Gated on `"clock"` in `caps`; the page is `/clock`. Settings persist in NVS
under the feature's own namespace, and `/api/status` carries a `clock` block
(`on`, `synced`, `tz`).
| Endpoint | Meaning |
|---|---|
| `GET /api/clock` | Every setting — `on`; `tz`, a POSIX TZ string (`KST-9`, `CET-1CEST,M3.5.0,M10.5.0/3` — note the inverted sign, and that this form carries the DST rule); `h12` (no AM/PM marker); `rot` (quarter turns of the panel; 1 is upright, the way the device's own menus read, and the default; 0 is the wide way); `face` (an index into `faces`, the names of the typefaces compiled in); `gap` (px between the rows upright, between the pairs wide); `sep` (what sits between: 0 nothing, 1 a bar cut from the pattern exactly as the digits are — across, the face's own colon instead of a bar); `sepw` (the bar's thickness); `in` (inside the digits: 0 the pattern, 1 the ink colour); `out` (outside them: 0 the pattern at `dim` percent, 1 the `bg` colour); `ink` and `bg` as `RRGGBB`; `fade` (crossfade the minute change) — plus `w`/`h` (the panel's native size), `glyphsRev` (the digit set compiled in), and what the panel currently believes: `synced`, `time` (`HH:MM:SS`), `today`, `zone` (the abbreviation in force, so summer time is visible). |
| `POST /api/clock` | Any subset of the same fields, form-encoded; partial updates are the point — the page sends each control as it changes. A changed `tz` restarts NTP against the new zone. Answers as `GET`. |
| `GET /clock/glyphs.bin` | The digit glyphs the firmware draws from, as compiled in — every face, two sizes each (`Cache-Control: immutable`; the page keys on `glyphsRev`). The page's preview draws the same pixels as the panel from this and lists the faces from the same bytes. Format, and how to add a face, in `firmware/toolchain/build_clock_glyphs.py`. |
The zone is the clock's. Another feature may set the C library's `TZ` from a
setting of its own — the weather page's UTC offset does, on save — and the
clock puts it back within a second. Nothing is drawn while `synced` is false.
## Wi-Fi
Requires `PF_WIFI_HTTP_ENABLED` (default on). Up to `PatternflowWifi::MAX_NETWORKS` are remembered and tried in order.
| Route | |
|---|---|
| `GET /api/wifi` | `{max, connected, current, ip, status, bootIdx, join, networks:[{ssid}]}` — SSIDs only, never passwords. `join` is the last network someone asked for (below). |
| `POST /api/wifi?ssid=…&pass=…` | Store a network. Add `connect=1` to switch immediately. On the hotspot with no station link it is tried at once either way. |
| `DELETE /api/wifi?ssid=…` | Forget one. `404` if it was not saved. |
| `POST /api/wifi/boot` (`bootIdx=…`) | Which saved slot the next boot tries first. Stored in NVS, deliberately **not** applied live — the request rides the connection it would drop. |
| `POST /api/wifi/reboot` | Replies `{ok:true}` first, restarts 400 ms later. |
| `POST /api/wifi/reconnect` | Since 1.5. Replies `{ok:true,reconnecting:true}`, then requests a reconnect after 500 ms using the current credentials. Does not reboot or rewrite NVS. The link briefly drops; poll status after reconnecting rather than assuming the reply means it is already back. |
`POST` stores without switching by default: the usual reason to add a network here is to pre-register somewhere the device is *going*, and switching now would drop the connection serving the request and lose the reply. With `connect=1` the reply is sent first and the link torn down after.
Passwords travel in the clear over LAN HTTP and are never sent back. Same trust model as `/update` and `/patterns`.
`join` reports what became of the last network sent with `connect=1` (or by Improv), since 1.5: `{state, ssid, ip, why, reason, ago}`. `state` is `none` (nothing asked for since boot - a join at power-up is not reported), `trying`, `joined` (with the address it got in `ip`) or `failed` (with `why`: `wrong password`, `network not found`, `the network refused the panel` or `no answer from the network`, and the driver's `reason` code behind it). A join is given up on after 30 s, or when the hotspot's attempt window closes. `ago` is seconds since it was opened or settled. The panel also shows the outcome on its screen - `JOINED` and the address for a minute, or why not for twenty seconds - because a phone that sent the network from the hotspot often loses the page at that moment.
### Hotspot
Since 1.5. The panel is a network of its own when it has none: an access point named after the
board (`patternflow-a1b2`, the alias the NETWORK screen shows), WPA2, password `patternflow` until
the owner changes it. The console is at `http://192.168.4.1/` on it, every route in this document
included, so a phone can add the next place's Wi-Fi from the hotspot. Modes: `off`; `auto` (the
default - up about fifteen seconds after the panel last had a network, down again once one is
joined and nobody is on the hotspot); `always` (up beside the station link too, on its channel).
The channel is the least loaded of 1/6/11 from a scan when the panel is alone.
| Route | |
|---|---|
| `GET /api/hotspot` | `{ok:true, hotspot:{mode, up, ssid, ip, channel, clients, dns}, seen:[{ssid, rssi}], pass}` - the same object status carries, plus the password, and `seen`: the network names the channel scan found when the hotspot came up, strongest first, at most twelve - what the `/wifi` page suggests. Empty when the hotspot came up beside a station link (no scan). |
| `POST /api/hotspot` (`mode=off|auto|always`, `pass=…`) | Either or both. `400` with an `error` for an unknown mode or a password outside 8-63 characters. A change is saved to NVS and applied at once: a hotspot that was up restarts, which drops whoever was on it. Replies like `GET`. |
On the hotspot every DNS name resolves to the panel, so `patternflow.local` and any typed name land
on the console; the phone's own "is there internet" probe fails, on purpose, within a second - the
network says it has no internet, which is true, and the phone stays on it. A panel on its hotspot
runs the radio AP-only: the station interface comes back for a rare retry when nobody is connected,
and at once when `POST /api/wifi` brings credentials.
## Shows (Sequences)
`.pfs` cue tables live on the pattern volume under `/shows` and play on a wall clock — cues fire by `millis()`, not by frame. Format: PFST v1 (whole-second cues) and v2 (deciseconds + eased cues); see `docs/pfst-v2-spec.md`.
| Route | |
|---|---|
| `GET /show` | The Sequences console page. |
| `GET /api/shows` | Player status plus the catalog: `shows:[{slug,title,length,cues,loop}]`. |
| `GET /api/shows/status` | Status alone: `playing`, `paused`, `t` / `length` (seconds), `slug`, `title`, `missing[]`, playlist / variance / schedule state. |
| `PUT /api/shows` (raw body, `X-PF-Name: <name>.pfs`) | Install a table. The slug is derived from the name. |
| `DELETE /api/shows?slug=…` | Remove one; a playing show is stopped first. |
| `POST /api/shows/control` | `op=play&slug=…[&loop=1]` · `op=playlist&slugs=a,b[&loop=1]` · `op=pause` · `op=resume` · `op=stop` · `op=loop&loop=1` · `op=variance&en=…&cue=…¶m=…` |
| `POST /api/shows/schedule` | Night/wake schedule — fields as on the page. |
**Pause banks the wall clock, resume re-bases it** — cues (and a v2 ease mid-ramp) continue exactly where they stopped, and the paused look stays on the panel. `missing[]` lists pattern names a show calls that are not on the device; those cues are skipped rather than failing the show. A show's opening cue may name a pattern — the device switches to it when playback starts, matching preset names and module filenames case-insensitively.
## Firmware update
Requires `PF_WEBUPDATE_ENABLED` (default on).
| Route | |
|---|---|
| `GET /update` | Drop-zone page |
| `POST /update` | Multipart firmware image, streamed into `Update.h`. What the `/update` page sends. |
| `PUT /update` | The same image as a raw body, for scripts: `curl -T patternflow.ino.bin "http://<panel>/update?size=<bytes>"`. Same gate, same reply, same reboot. |
| `GET /update/status` | `{armed, busy, version, lastError, lastRejected, lastOk, received, expected, attempts}` |
`armed` is the gate. With `PF_WEBUPDATE_ALWAYS_ARMED 1` (the default) it is always true and anyone on the Wi-Fi can flash the device from a phone browser — the same exposure ArduinoOTA's no-password default already has, and the right call on a home or studio network. Set the flag to `0` for shared, office or exhibition Wi-Fi and uploads are refused unless the UPDATE screen is physically open on the device (hold K2 → NETWORK, turn K4).
An incoming image also wakes a sleeping device.
An upload may stall for up to two minutes — a Wi-Fi hitch halfway through a flash — before the device gives up on the connection; before 1.5 it was five seconds. The image goes to the other app slot, so an upload that is dropped leaves the running firmware exactly as it was.
## Identifying a device
There is **no** MAC address, serial number or other hardware identifier on any endpoint. What exists:
- `status.host` — the mDNS hostname, which is `"patternflow"` on every device out of the box.
- `status.ip` — DHCP, so it moves.
- `status.panel` and `status.version` — a model and a firmware version, not an identity.
A client that has to key on something stable should use the hostname and require that a second device on the same network be given a different `PF_OTA_HOSTNAME` — which `net_config.h` already asks for, and which is also the only way `patternflow.local` resolves to the right board. Detect the collision (same hostname, different IP) and say so; do not quietly overwrite the first device.
## Choosing a transport
| | HTTP | OSC | MIDI | MQTT |
|---|---|---|---|---|
| Read device state | **yes**, everything | announce burst + heartbeat | pattern changes only (Program Change) | knob / pattern / sleep topics, in Publisher role |
| Read knob positions | **yes**, `/api/mqtt`, any role | on change | on change, relative (CC 24–27) | in Publisher role |
| Write knobs | **no** | **yes**, deltas | **yes**, absolute (CC 20–23) and deltas (CC 24–27) | **yes**, Subscriber role only |
| Switch pattern | **yes** | yes | yes, Program Change | Subscriber role only |
| Sleep / wake | **yes** | no | no | **yes**, either role |
| Install / delete patterns | **yes** | no | no | no |
| Wi-Fi, firmware, calibration | **yes** | no | no | no |
| Latency | poll-bound, seconds | sub-frame | sub-frame | broker-bound |
| Needs infrastructure | nothing | nothing | an RTP-MIDI driver on the host (built into macOS/iOS; rtpMIDI on Windows) | a broker |
In short: HTTP is the management and state transport, OSC and MIDI are the low-latency performance transports (MIDI is the one a DAW already speaks, and the only one with absolute knob values — [`midi-spec.md`](midi-spec.md)), MQTT is the one that reaches into home automation and mirrors one panel onto another. A home-automation client wants HTTP for everything except knob writes, and MQTT for those.
## Conventions for future additions
- JSON lives under `/api/`; anything else on this server is a page for a person.
- Parameters are query-string or form-encoded. There is no JSON request body anywhere, and adding one would need a body parser the firmware does not have.
- Errors are `{"ok":false,"error":"<lowercase sentence>"}` with a `4xx`/`5xx` status. Success bodies vary; the ones that report a mutation start with `"ok":true`.
- A handler must never touch FATFS, the ELF loader, the DMA engine or the CPU clock. Queue the work and let `loop()` do it; the reply then reports the pre-transition state, which callers already expect.
- A feature's endpoints exist only when that feature is composed in — the route itself is absent otherwise, so clients probe `caps` rather than expecting a soft 404. Core endpoints are gated by a core `PF_*_ENABLED` flag and must leave the sketch compiling at `0`.
- Nothing here may stream per-frame pixel data. That has been tried.
## Version history
- **1.5** (unreleased) — the hotspot (`hotspot` in status, `GET`/`POST /api/hotspot`); status gains `network` and `thumbs` diagnostics, `fsError` (why storage is not mounted) and `flashId`; a failed `POST /api/patterns/format` returns the reason as its `error`; `POST /api/wifi/reconnect` reconnects without a reboot; core name registration retries partial failures and preserves feature-owned services; the MIDI edition adds a `midiUsb` block to status ([`midi-spec.md`](midi-spec.md)); `GET`/`POST /api/knobs` and the `/knobs` page (encoder direction and edges per click, per knob, persisted); `PUT /update` takes a raw image, and an upload survives a stall of up to two minutes; `GET /api/wifi` gains `join` (what became of the last network asked for) and `GET /api/hotspot` gains `seen` (the names in range); a network added on the hotspot with no station link is tried at once; console pages are cached by build (`?v=` immutable, bare URLs `no-cache` with an `ETag` and `304`, another build's `v` redirected with `302`), the chrome by its CRC (`?h=`), only for a `Host` that can only be the panel; `GET /favicon.ico` answers `204`; status gains `build`, `viaHotspot` and `busy`.
- **1.4** (2026-09-06) — `GET /api/patterns/file` gains `ext=thumb`; `GET /api/display` takes `brightness` and status reports it; status gains `resetReason` and `load.internal`/`load.psram`; console pages are served gzip-compressed (`Content-Encoding: gzip`); the page sender no longer truncates on a slow link; the server no longer trips the Core-0 watchdog on a request that stalls mid-header.
- **1.3** (2026-09-04) — `GET`/`POST /api/clock` (Utility edition) and the `clock` block in status; `caps` gains `"clock"`.
- **1.2** (2026-09-03) — the server is serviced on Core 0 (the one-connection rule stands; the render-pays rule is history); status gains `httpCore`, `netStackMin`, `loopSyncServed`/`loopSyncMaxUs`; `POST /api/params` documents `d1`..`d4` and how a held value reaches a legacy pattern; `GET /api/patterns/select` gains `step`; `GET`/`POST /api/audio` (Audio-React) are documented; `featureNav`'s microphone label is *Audio*.
- **1.1** (2026-08-30) — status gains `lanes`/`laneActive` (the continuous lane per knob) and `featureNav` (feature-served console pages); the microphone endpoints (`/api/audio-in`) are documented; port 81's WebSocket is promoted from "not part of this contract" to its own spec, [`audio-ws-spec.md`](audio-ws-spec.md); the endpoint-gating convention now describes composition gating.
- **1.0** — first written contract for the HTTP API. Covers status, sleep, display calibration, patterns (list / select / sidecar / install / delete), the MQTT status and configuration endpoints, Wi-Fi, and firmware update; documents the single-connection rule, the console-pause rule, the queued-write rule, and the knob read/write asymmetry.
3230db654This file is pinned to the selected release. Download the original to open it in your design tool.