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.
| 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. |
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.)
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. |
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.
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.
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.
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.
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.
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.
{
"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.
| 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.
| 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.
| 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.
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.
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.
{
"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.
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).
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, andInputFrameare 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.
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
- 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=Nplus the bare names (band=2&inMax=0.5) — one band per request. Abandoutside 0..3 is400 {"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'shzMin,lut,knob,mutedoroutMinwas, 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.
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. |
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.
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.
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.
.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.
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.
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.panelandstatus.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.
| 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.
- 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 a4xx/5xxstatus. A path no route serves is the one exception: its404is 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 (loopStalledin 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 onceloopStalledreadsfalseagain, which a pattern stuck indraw()never lets it: that panel has to be restarted (POST /api/wifi/rebootstill 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 usesPFLoopSync::run()answers afalsethe same way, withPatternflowHttp::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
capsrather than expecting a soft 404. Core endpoints are gated by a corePF_*_ENABLEDflag and must leave the sketch compiling at0. - Nothing here may stream per-frame pixel data. That has been tried.
- 1.5 (unreleased) — the hotspot (
hotspotin status,GET/POST /api/hotspot); status gainsnetworkandthumbsdiagnostics,fsError(why storage is not mounted) andflashId; a failedPOST /api/patterns/formatreturns the reason as itserror;POST /api/wifi/reconnectreconnects without a reboot; core name registration retries partial failures and preserves feature-owned services; the MIDI edition adds amidiUsbblock to status (midi-spec.md);GET/POST /api/knobsand the/knobspage (encoder direction and edges per click, per knob, persisted);PUT /updatetakes a raw image, and an upload survives a stall of up to two minutes;GET /api/wifigainsjoin(what became of the last network asked for) andGET /api/hotspotgainsseen(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 URLsno-cachewith anETagand304, another build'svredirected with302), the chrome by its CRC (?h=), only for aHostthat can only be the panel;GET /favicon.icoanswers204; status gainsbuild,viaHotspotandbusy;loadgainscode(where the module's code runs from); pattern names inGET /api/patternsand/api/patterns/selectare 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;moduleMemorygainscodePolicy; status gainsloopStackMin; the hotspot password is JSON-escaped in/api/hotspot; aPOSTthat is not multipart to/api/patternsor/updateis answered instead of crashing the panel, a multipart boundary longer than 70 characters is refused, and a rawPUTsees its own query string; status gainscrash(the core dump's summary, and the pattern and phase the reset interrupted) andDELETE /api/crashclears it; status gainsloopAgeMs,loopStalledandloopSyncGaveUp, and a route that needs the render loop answers503(render loop is not answering) when a pattern never returns fromdraw(), 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 -loadgainsresumedandresumeUs,moduleMemorygainsresident, and a module's data sections go to PSRAM first;POST /api/audio-inapplies a band when any of its suffixed fields is present (it took one of five before, and answeredokto the rest without changing anything), and refuses a badbandbefore applying anything, withok:falsein the body;POST /api/audio-in/resettakesband=Nand names the band in its answer, and a full reset now includesattack;hzRangeis printed to two decimals; a microphone that failed to start or stopped answering (sourcebeginningsynth) no longer drives the knobs from the test signal;/api/audio-inignores a value that is not a finite number (nan,inf) where it used to store it and printnanback, which no JSON parser reads, and a band whose low edge is in the top analysis bin reads 0 instead of puttingnaninto the poll and intoaudioInin status; the microphone section documents what was already there -smoothing,attack,meta,lutSet,lut, the suffixed field names,env,ext, theframepost,synth (mic stalled), thatspechas 64 buckets, not 40, and that a body sent without the formContent-Typeis 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..d4alone are a wholePOST /api/params,capsnames what each edition adds, and only a path no route serves gets a plain-text404. - 1.4 (2026-09-06) —
GET /api/patterns/filegainsext=thumb;GET /api/displaytakesbrightnessand status reports it; status gainsresetReasonandload.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 theclockblock in status;capsgains"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/paramsdocumentsd1..d4and how a held value reaches a legacy pattern;GET /api/patterns/selectgainsstep;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) andfeatureNav(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.