Skip to content

Latest commit

 

History

History
624 lines (477 loc) · 70.8 KB

rest-api.md

File metadata and controls

624 lines (477 loc) · 70.8 KB

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 is the one for MQTT. The four are not interchangeable — see 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.
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.
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. ?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 therefore returns the previous state. GET /api/patterns/select answers with the pattern it queued, and /api/patterns and /api/status name the old one until the next frame. 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).

{
  "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,
  "loopAgeMs": 9, "loopStalled": false, "loopSyncGaveUp": 0,
  "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); 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.
crash Since 1.5. Where the last death happened; absent when there is nothing to report, which is the normal case. See The crash record.
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.
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 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). resident (since 1.5) describes the modules kept loaded after another pattern took over, so picking one again resumes it instead of loading it: count parked now, bytes of PSRAM they hold (sections plus their own allocations), slots the most that may be parked (PF_MODULE_RESIDENT_MAX, 16; 0 when residency is compiled out), and boot counters resumes and evictions. A parked module holds no internal RAM - one that does is unloaded instead. Parked modules are evicted least recently used first whenever a fresh load would otherwise start with less than runtimeLimit plus 512 KiB of PSRAM free, and whenever a running module's own allocation would leave less than 512 KiB free or finds PSRAM full; they do not make way for allocations by features or the core. All of them go at once before a refused load is retried and when an upload, delete or format begins. A module loaded before any module file was last written - by those, or by a feature installing a pattern - is never resumed: parked, it is evicted at the next chance; running, it is unloaded when left, and at the list rebuild that follows the write.
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.
loopStackMin Since 1.5: the same for the loop task, which runs every pattern's draw() and the panel blit inside it.
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.
loopAgeMs / loopStalled / loopSyncGaveUp Since 1.5. Milliseconds since loop() was last at its frame boundary, read without the loop's help — a frame or two on a running panel. loopStalled turns true at 20 s (PF_LOOP_STALL_MS): the render loop has not come round, which in practice is a pattern that never returns from draw() — active names it. It can also be a healthy loop held up: with the internet down one feature lookup holds the loop 14 s, two features' lookups in the same iteration (Performance: weather and an MQTT broker named by host) 28-30 s, and a TLS handshake longer still, so true there is not yet a hang. What tells a hang from a loop that is only held up is the next poll: a hung loop's age has grown by exactly the time between the two, a busy one's is back to a few milliseconds with loopStalled false (runtime.maxUs is the longest it has been held since boot). The panel holds its last frame; this endpoint and POST /api/wifi/reboot keep answering, as does /update while it is armed without the UPDATE screen (the default), and every route that waits for the loop answers 503 (see Conventions). Two limits: the console's routes are registered by the loop on the first network link, so a loop that hangs before the panel has ever joined a network or raised its hotspot has no console to outlive it; and routes that only queue work for the loop - POST /api/params, /api/sleep, a brightness change - still answer ok on such a panel and do nothing until it returns. loopSyncGaveUp counts the requests refused that way. Nothing reboots the panel on its own, and a reboot goes back to the remembered pattern unless that pattern hung within 15 s of the boot that restored it — pick another one before it hangs again.
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. Since 1.5 a module that is still parked (moduleMemory.resident) comes back without loading, in the frame it is picked - when no load is in flight and no upload, delete or format is holding pattern switches. Picked during either, it is applied once that is over: resumed if it is still parked then, loaded if not (an upload, delete or format evicts every parked module).
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). Since 1.5 code says where the module's code runs from, "psram" or "internal": in PSRAM it is counted in psram and takes nothing from the internal heap, and "internal" on a module means PSRAM could not hold it or moduleMemory.codePolicy is 0 - which it becomes, until reboot, if code placed in PSRAM ever fails to read back on this unit. Since 1.5 data sections go to PSRAM first as well, whatever their size, so internal is 0 on a module that loaded normally. Since 1.5 load describes the module on the panel: resumed is true when it came back from residency rather than from a load - the timings are then its own, from when it first loaded, and nothing was read or set up this time - and resumeUs is what coming back took; false and 0 after a fresh load. With no module on the panel (a preset, or a load that failed) internal and psram are 0, code reads "internal", resumed is false and resumeUs 0; the timings are left as the last module had them - except after a failed load, where read, and relocate if the load got that far, describe the attempt that failed.
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 adds its own — Audio adds osc, audio, audio-in and midi, Performance adds clock, 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.
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.

The crash record

resetReason says that the board restarted on its own. crash, since 1.5, says where. It reports and nothing else: no pattern is forgotten and no reboot is forced because of it.

"crash": {
  "pattern": "cell_ripple", "phase": "draw",
  "code": { "base": "0x43c81000", "size": 4660 },
  "dump": {
    "fromThisReset": true, "task": "loopTask", "cause": 28, "vaddr": "0x00000000",
    "pc": "+0x1a1",
    "backtrace": ["+0x1a1", "+0x9c", "0x4200f1a3", "0x4200e907", "0x42010b52"],
    "corrupted": false, "build": "3f9a0c1e5d7b2a40", "bytes": 23108
  }
}

It has two halves, kept apart because they do not live equally long.

pattern, phase, code are a breadcrumb the firmware keeps in a corner of RAM that no boot initialises, which a panic or watchdog reset does not clear and a power cut does. They are present only on the boot that follows a panic, task_wdt, int_wdt or wdt reset, and describe the reset resetReason names.

Field Meaning
pattern Slug of the pattern that was resident (a module's file name without .pfm; a preset's name, slugified). "" when none was.
phase What the device was in the middle of: "loading" (reading and relocating a module, through its entry point), "constructors", "setup", "update", "draw", or "idle" — anywhere outside pattern code: the blit, a feature, the network. "dump-read" means the previous boot died while reading the core dump, and this boot left it unread. A pattern chosen while the panel is running loads on a task of its own, pf-load, while loopTask keeps drawing, so read phase beside dump.task: "loading", "constructors" or "setup" with a crash in pf-load is a crash in the load, and with a crash in another task it is one that happened while a module was loading, not in it. The exception is the pattern restored at boot, which is loaded on loopTask before the first frame — there, and for a preset's setup(), which also runs at boot, "setup" with loopTask is a crash in setup().
code Modules only: the address the CPU fetched the module's code from, and its size in bytes. 0x43… is PSRAM as the instruction bus sees it (load.code "psram"); 0x40… is internal RAM.

dump is the summary of the core dump the SDK writes to the coredump partition on every panic. It survives power cycles and reflashing, and stays until the next panic overwrites it or DELETE /api/crash erases it. The SDK was writing these dumps before any firmware read them, so a board that panicked on an earlier firmware still holds that dump: after the update it is reported with fromThisReset false, on every boot, until it is cleared. It is not a new crash.

Field Meaning
fromThisReset Whether the dump was written by the reset this boot followed. Only then do the breadcrumb and the dump describe one event. false after a power cycle, and after a death that wrote no dump of its own — an RTC watchdog reset, or a dump too large for the 64 KB partition — which leaves an earlier one in place.
task Name of the task that took the exception.
cause Xtensa EXCCAUSE as recorded: 0 illegal instruction, 6 integer divide by zero, 20 instruction fetch prohibited (a jump to nowhere), 28 load prohibited, 29 store prohibited (a bad pointer read or written). 29 with vaddr 0x00000000 and panic_abort at the top of the backtrace is not a bad pointer but the SDK stopping on purpose, which it does by storing to address zero: abort(), a failed assert, the stack-smashing check and the task watchdog all look like this. A value of 64 or above is not an Xtensa cause but one of the SDK's own, offset by 64: 69 and 70 are the interrupt watchdog on core 0 and 1, 71 a cache access error.
vaddr The address that faulted, for 28 and 29.
pc, backtrace Where it happened and up to 16 return addresses, innermost first, as the SDK's own summary gives them — the Backtrace: line of a serial panic, including its convention that every address is three bytes short of the real one, so that a return address resolves to the call that made it. backtrace[0] is pc. An address inside the module code describes is written as an offset into it, "+0x1a1", when fromThisReset is true; every other address is "0x…". After a task_wdt or int_wdt reset the backtrace is the interrupt's stack — the watchdog's handler, not the code that was stuck — and the breadcrumb is the useful half.
corrupted The SDK could not walk the stack to its end; the addresses up to that point still hold.
build Sixteen hex digits of the ELF hash of the image that crashed. Its first eight are that image's build; when they are not this reply's build, the dump predates an update.
bytes Size of the dump image in flash.

Decoding. A "0x…" address is firmware code: xtensa-esp32s3-elf-addr2line -pfiaC -e firmware.elf 0x4200f1a3 … against the ELF of the image named by build (its SHA-256 starts with those digits). A "+0x…" address is an offset into the module's .text, and a .pfm carries its own symbol table: xtensa-esp32s3-elf-nm -nC cell_ripple.pfm lists its functions by offset, and the one at or below the address is where it was. Without fromThisReset a module address stays raw: nothing recorded where that module had been loaded. The same record is printed on serial at boot as [CRASH] … lines. The raw dump is not served: it is every task's stack, and whatever was on one.

DELETE /api/crash

Erases the core dump and drops the record; crash is absent from the next status reply. {"ok":true}, or 404 with {"ok":false,"error":"no crash recorded"} when there was nothing to clear. The erase is 64 KB of flash, and the panel holds its last frame while it runs.

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. A request needs at least one pN or dN. 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.
{ "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

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

{
  "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": "-O2"
}

knobs is the four knob labels in logical order (see 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.

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

{
  "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: 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. The phone capture app is a second client of them: it reads the configuration, and posts what it hears so the page has something to draw on a panel with no microphone.

Every level here — inMin, inMax, levels, env, spectrum — is linear amplitude, not decibels, and not clamped: a loud pure tone reads well above

  1. The page draws a dB axis and converts at its own edge.
Endpoint Meaning
GET /api/audio-in The configuration, read once: source (below), 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), smoothing and attack (the two glide speeds, 0.05..0.9: falls and rises), bands (four, below), hzRange ([31.25,8000.00], the bounds a band is held to; two decimals since 1.5). Also carried, for scripts and for diagnosing a microphone: rawPeak/rawDc (the last window before gain), windows (read since boot), level and out (four raw band levels and what they map to) and spec, a 64-bucket log spectrum.
GET /api/audio-in?levels=1 The polling shape, no configuration in it: source, ext, rawPeak, rawDc, dropped, levels (four damped band levels), levelsN (the level normalized inside the band's tracked range - what the mapping consumes in auto mode; 0 while that band's gate is closed), outputs (what each band maps to, 0..1), env (four {lo,hi}, the tracked range — present only in auto mode or with ext), and spectrum (64 log-spaced buckets across hzRange). A poll counts as someone watching for 2.5 s, which the phone app reads to decide how often to post; add idle=1 to a poll that should not count.
POST /api/audio-in Any subset of the settings; a request changes only the fields it carries, and answers {"ok":true}. mic=0/1 switches the whole feature (installs/releases the I2S driver). micGain=1..16, auto=0/1, smoothing and attack (0.05..0.9). Band fields in either spelling below. frame=… is the phone's monitor frame (76 numbers: 4 levels; 4 × lo,hi; 64 buckets): nothing else in that request is read, nothing is saved, and the answer adds watch — {"ok":true,"watch":true} while a page is watching, false when none is.
POST /api/audio-in/reset The firmware's measured defaults. With no argument: all four bands and their curves, smoothing, attack (since 1.5) and micGain — not the microphone switch, not autoRange. With band=N (0..3, since 1.5): that band and its curve only, and the answer names it, {"ok":true,"band":N}. Any other band is 400 and resets nothing. Firmware before 1.5 ignores band and resets everything; the missing band in its answer is how to tell.

Band fields. Each of bands[0..3] has hzMin/hzMax (what it listens to), inMin/inMax (the level window mapped onto the knob in manual mode), gain (the response exponent, 0.2..4, used while the band has no curve table), outMin/outMax (where the knob rests and peaks, 0..1 each; outMax below outMin is a knob that falls as it gets louder), knob (0..3; two bands may name one knob and the lower band wins it), muted, meta (up to 31 bytes the editor stores to describe its curve; echoed verbatim, never parsed) and lutSet (read-only: the band runs a stored curve table instead of gain).

To write them there are two spellings, and both take any subset:

  • band=N plus the bare names (band=2&inMax=0.5) — one band per request. A band outside 0..3 is 400 {"ok":false,"error":"band must be 0-3"} and nothing in the request is applied.
  • the names with the band appended (inMax2=0.5&hzMin0=62&hzMax0=375) — any number of bands per request. Since 1.5 a band is applied when any of its fields is present; before, only a request carrying that band's hzMin, lut, knob, muted or outMin was, and anything else was answered {"ok":true} and ignored.

lut (write-only) is the curve table: exactly 33 integers 0..255, commas only. An empty lut clears the table and the band goes back to gain; a value that is not exactly 33 integers is ignored and the answer is still {"ok":true}. The firmware does not tie meta to lut — a client that names a curve sends its table in the same request.

Two pairs are settled together after a request's fields are in, whichever half arrived: hzMin/hzMax are held to hzRange, put in order and kept at least one analysis bin (31.25 Hz) apart; inMin/inMax are held to 0..1 with inMax at least 0.01 above inMin. One half sent alone can therefore move the other — send both halves of a pair when either changes. The bin's width is kept only where the range has room for it: a low edge within 31.25 Hz of the top is stored as sent, and from about 7984 Hz up the band covers no analysis bin, so it reads 0 and its knob rests at outMin.

A value that is not a finite number — nan, inf, one too large for a 32-bit float — is ignored: the field keeps what it had, and the answer is still {"ok":true}. A frame with one in it is dropped whole, like a frame of the wrong length. Before 1.5 such a value was stored and printed back as nan, which is not JSON: the configuration could not be parsed again until the field was overwritten or reset. One that is already stored is replaced at boot — a band holding one goes back to its defaults (its curve is kept), and so do micGain, smoothing and attack. Text that does not start with a number is still read as 0.

Send the form Content-Type. Like every route on this server, these read a request body as arguments only when it is application/x-www-form-urlencoded (or multipart). curl -d sends that by itself; a browser's fetch with a string body does not (it says text/plain, and the body arrives as one argument named plain). The settings POST then changes nothing and answers {"ok":true}. The reset is the one that bites: band=2 in a body the server did not read is a reset with no band, which resets all four. Put the header on, or put band in the query string, and check that the answer names the band.

source says what the input is, and the strings are the contract:

source
off The switch is off. The analysis is parked and the poll's numbers are whatever the last window left — not zeroed.
pdm The microphone is delivering samples. The only state in which it drives the knobs.
pdm (no mic - data pin idle) The driver is running but the data pin sits at a rail: no microphone, or no data wire. Nothing is driven.
synth The switch is on and the driver is not running — it failed to install, or has not yet (the first quarter second after mic=1).
synth (mic stalled) The driver is running and has stopped returning samples.
phone ?levels=1 only, with ext:true: the switch is off and the phone app posted a frame in the last three seconds. levels, env and spectrum are then the phone's, already normalized 0..1 on its own scale; levelsN and outputs are still the microphone's and describe nothing on screen.

In the two synth states the analysis runs on a built-in three-tone test signal, which is what the poll then reports. Since 1.5 that signal no longer reaches the knobs.

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. It is one switch with three handles: the AUD row of the panel's NETWORK screen, the Browser extension and phone app row under Sources on the console's /audio-in page, and these two routes. The browser extension reads it once each time its socket opens and offers to turn it on (audio-ws-spec.md).

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 (Performance 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

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=…&param=…
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/status on change on change, relative (CC 24–27) in Publisher role
Write knobs yes, absolute (POST /api/params) and deltas (d1..d4) 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 of the two the one with absolute knob values — midi-spec.md), MQTT is the one that reaches into home automation and mirrors one panel onto another. A home-automation client can do all of it over HTTP, knob writes included.

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. A path no route serves is the one exception: its 404 is plain text. Success bodies vary; the ones that report a mutation start with "ok":true.
  • A route whose handler has to run on the loop task answers 503 {"ok":false,"error":"render loop is not answering"} when the loop has stopped coming round (loopStalled in status) — the pattern list, select, install, delete, format, the knob settings, and a feature's routes that reconfigure what its loop hook is ticking. Nothing was changed. Retrying helps only once loopStalled reads false again, which a pattern stuck in draw() never lets it: that panel has to be restarted (POST /api/wifi/reboot still answers). A request made in the first twenty seconds after the loop stops waits out the rest of them for that reply; later ones get it at once. A new handler that uses PFLoopSync::run() answers a false the same way, with PatternflowHttp::sendLoopStalled().
  • 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); 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; load gains code (where the module's code runs from); pattern names in GET /api/patterns and /api/patterns/select are JSON-escaped, so a quote or backslash in a title no longer makes the reply unparseable; a multipart request that stops mid-form is dropped - the connection is closed with no reply - instead of spinning the server into its watchdog, and leaves none of its fields behind for later requests; moduleMemory gains codePolicy; status gains loopStackMin; the hotspot password is JSON-escaped in /api/hotspot; a POST that is not multipart to /api/patterns or /update is answered instead of crashing the panel, a multipart boundary longer than 70 characters is refused, and a raw PUT sees its own query string; status gains crash (the core dump's summary, and the pattern and phase the reset interrupted) and DELETE /api/crash clears it; status gains loopAgeMs, loopStalled and loopSyncGaveUp, and a route that needs the render loop answers 503 (render loop is not answering) when a pattern never returns from draw(), where it used to take the whole server down with it; a module that has run stays loaded when another pattern takes over and resumes when picked again - load gains resumed and resumeUs, moduleMemory gains resident, and a module's data sections go to PSRAM first; POST /api/audio-in applies a band when any of its suffixed fields is present (it took one of five before, and answered ok to the rest without changing anything), and refuses a bad band before applying anything, with ok:false in the body; POST /api/audio-in/reset takes band=N and names the band in its answer, and a full reset now includes attack; hzRange is printed to two decimals; a microphone that failed to start or stopped answering (source beginning synth) no longer drives the knobs from the test signal; /api/audio-in ignores a value that is not a finite number (nan, inf) where it used to store it and print nan back, which no JSON parser reads, and a band whose low edge is in the top analysis bin reads 0 instead of putting nan into the poll and into audioIn in status; the microphone section documents what was already there - smoothing, attack, meta, lutSet, lut, the suffixed field names, env, ext, the frame post, synth (mic stalled), that spec has 64 buckets, not 40, and that a body sent without the form Content-Type is not read as arguments; where this file said two things, the route's own section now stands: select answers with the pattern it queued, HTTP writes knobs and reads them from /api/status, d1..d4 alone are a whole POST /api/params, caps names what each edition adds, and only a path no route serves gets a plain-text 404.
  • 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; 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.