Features that attach to the core without the core knowing they exist.
New here? Read
docs/EDITIONS.mdfirst. It is the short version of what a feature is, what an edition is, and which promises every build has to keep. This file is the reference underneath it: the hooks, in full, and what proved each one.
This directory is the answer to one question: when the core updates, how does somebody else's firmware take the update without pain? Git merges are only painful when both sides edit the same files — so the rule is that a firmware built on the core edits none.
A firmware adds directories here and two files of its own. It edits nothing.
git merge upstreamhas nothing to conflict over, ever.
An earlier version of this said "and one line to features.h" — which was
one line in a core file, so every firmware conflicted on it at every core
update, forever, on the same line. One line is enough to make taking
updates a chore that eventually stops happening, and a firmware that stops
taking updates is the fork this directory exists to prevent.
| the firmware's own file | what it decides |
|---|---|
features_local.h |
which features this firmware has, in what order |
overrides.h |
any #ifndef-guarded setting: transmit power, panel clock, its own name and version |
Both are gitignored here, because they belong to a firmware rather than to
the core. The ones this repository publishes live in
firmware/bundles/ — build.sh audio copies a
pair in, builds, and takes them away again.
Somebody building a firmware outside this tree writes the same two files. That is the entire difference between a bundle and somebody else's firmware: where the two files live, and therefore who to ask when it breaks.
See docs/rfc-core-and-variants.md
for why any of this exists.
| file | what it is |
|---|---|
pf_feature.h |
The interface. What a feature may be asked, and what it is told. |
pf_features.h |
The dispatcher. Walks the list and fans each moment out. |
features.h |
The default list, and the escape hatch. The core owns it; no firmware edits it. |
feature_presets.h |
Patterns contributed by features. Same arrangement: #ifndef PF_FEATURE_PRESETS, so a firmware can decline them. |
A firmware's features_local.h defines PF_FEATURE_LIST and features.h steps
aside. It may add, drop and reorder; PF_FEATURES_NONE builds with none at
all:
#include "osc/feature_osc.h"
#include "audio_in/feature_audio_in.h"
#define PF_FEATURE_LIST \
&PFFeatureOsc::descriptor, \
&PFFeatureAudioIn::descriptoroverrides.h is included from config.h before any default applies, so
it reaches settings the feature list cannot:
#define PF_VARIANT "audio"
#define PF_VARIANT_VERSION "v0.1.0" // reported in /api/status, worn as
// a badge in the console header
#define PF_AUDIO_IN_DRIVES_KNOBS 1 // a feature's own default, changed
#define PF_FEATURE_PRESETS // no presets from features you lackA feature is a PFFeature — a name, a capability string, and a set of function
pointers. Every hook is optional: leave a field nullptr and that moment
passes the feature by.
Derived from what real features actually needed, not from imagination — each row says which port proved it.
| hook | when | proven by |
|---|---|---|
setup() |
boot, before Wi-Fi | show player, weather config, MQTT role |
onNetwork() |
Wi-Fi connected, and every reconnect — register HTTP routes here | /show, /weather, /mqtt, /audio-in, /api/audio |
loop(frame) |
every frame; must not block | show cue table, weather polling, MQTT client |
observeFrame(input, frame) |
the finished input frame, for features that mirror rather than produce | MQTT publishing knob values; OSC reporting outward |
fillInput(input) |
before the pattern sees the frame — drive a lane from a reading | weather, audio bands, MQTT deltas |
onUserInput() |
a human turned a knob or pressed a button | night/wake scheduler |
claimsPattern() |
"I am driving the pattern" — remote pickers stand down | a running show |
takePattern(&idx) |
"switch to this pattern, please" — the sketch performs it | show cues, MQTT pattern topic |
onSleep(bool) |
the panel slept or woke | MQTT state publishing |
requestSleep(&bool) |
"sleep / wake the device, please" — again a request | MQTT sleep topic |
shortName + isRuntimeEnabled() + setRuntimeEnabled(bool) |
the device's own NETWORK screen lists and toggles this feature | audio |
navPath + navLabel + navDesc |
the console header links the page, and the home screen gives it a row with that one-line description — the core never learns the path | /show, /mqtt, /weather, /audio-in |
appendStatus(String&) |
append ,"key":value fields to /api/status |
MQTT role/state |
drawOverlay(frame) |
after the pattern draws, before present | scheduler clock, weather clock |
composeFrame(frame, w, h) |
the finished canvas on its way to the panel, before the blit — return a buffer to show instead, or null | the clock feature: alpha-blended digits, and the pattern masked to the inside of them |
handleSelectInput(input) + drawSelect() + decorateSelect() |
the SELECT screen (K4's pattern browser): consume its knobs, draw it entirely, or add a hint after the default draw — so a feature can own an alternate browser without the sketch naming it | the show feature's Sequences playlist |
PFFeatureFrame carries what those hooks need so a feature never reaches into
the sketch's globals: dt, patternName, running, chromeVisible (the
device's own UI is on screen — decorative overlays stay off), plus
patternIndex and appMode for the one case that has to report raw
values outward on a published wire protocol.
takePattern is a request, not an action. Loading a module is the
sketch's job; a feature asks and the sketch performs.
It also reports who asked, and that changes what the sketch does afterwards. A feature that claims the pattern owns the panel while it runs — a show cycling cues — so its choices are transient and must not be written to NVS every cue. One that only asks is relaying a person: somebody picked a pattern from Ableton or a phone, and that should survive a reboot exactly as turning the knob does. Only a person's choice is remembered.
features/
yourthing/
feature_yourthing.h ← the descriptor: which function answers which hook
core_yourthing.h ← your actual feature, unchanged
inline const PFFeature descriptor = {
"yourthing", // name
"yourthing", // cap string reported in /api/status caps, or nullptr
setup, // or nullptr
onNetwork,
loop,
nullptr, // observeFrame
nullptr, // fillInput
nullptr, // onUserInput
nullptr, // claimsPattern
nullptr, // takePattern
nullptr, // onSleep
nullptr, // requestSleep
nullptr, // shortName - set it to appear in the device's NETWORK screen
nullptr, // isRuntimeEnabled
nullptr, // setRuntimeEnabled
nullptr, // appendStatus
drawOverlay,
"/yourthing", // navPath - the console header link, or nullptr
"Yourthing", // navLabel
"One line for the home screen's row.", // navDesc
};The order matters — these are positional. Copy the block from an existing feature and fill in what you need; the compiler catches a slot in the wrong place as a type error, which is how the ports found their own mistakes.
Then one line in the bundle that carries it — firmware/bundles/<edition>/features_local.h — and nothing else in the tree changes. features.h itself belongs to the core and is never edited (its header says why).
- The loop hook must not block. No
delay(), no waiting on a socket. The panel is not being drawn while it runs. - Buffers of 1 KB or more go through
PFMem(PSRAM). Internal heap is the scarcest thing on the board and it is what caps how big a loadable pattern can be. - Own servers and tasks are fine — the audio feature runs its own websocket port — as long as the loop hook itself stays quick.
- Settings live in your own NVS namespace. Read and write the core's existing keys (Wi-Fi, brightness, selected pattern) so users switch firmwares without re-provisioning, but never invent new keys inside a core namespace.
| feature | files | notes |
|---|---|---|
show/ |
player, HTTP page, night/wake schedule, library pull | The first port, deliberately the hardest. |
weather/ |
readings, HTTP page, corner clock | Grew the interface: fillInput, chromeVisible. |
mqtt/ |
client, all roles + FlowLocal, HTTP page | Grew it again: observeFrame, sleep in both directions, appendStatus. |
audio/ |
the WebSocket on port 81 that a client capturing sound elsewhere (the browser extension, the phone app) writes the four knob lanes through, and the switch that lets it (/api/audio) |
The one with a server of its own, and a row in the device's own menu. The analysis and the mapping happen in the client; no page is served from here. docs/audio-ws-spec.md. |
audio_in/ |
the on-board PDM microphone: FFT on a Core-0 task, four bands onto the knob lanes, /audio-in and /api/audio-in |
The first feature that did not already exist in the core; four hooks, no core edits. Its console page is assembled from the audio extension's mapping editor and the panel's own half in console/_audio_in_* (toolchain/build_audio_in_page.py, run by console_pages.py build). |
osc/ |
Max / TouchDesigner / Ableton, both directions | The fifth, and the first that did not fit — see below. |
midi/ |
MIDI in and out over RTP-MIDI (Wi-Fi) and, in the MIDI bundle, USB: CC 20–23 absolute, 24–27 relative, notes, Program Change | Transport-agnostic mapping in core_midi.h; the network transport beside it (core_midi_rtp.h), and the USB one (core_midi_usb.h) that compiles in only when the build's USB port is the OTG stack — a build flag, which is why the MIDI bundle has its own env (bundles/midi/env). docs/midi-spec.md. |
clock/ |
the time cut out of the running pattern, /clock, /api/clock |
Settings in its own NVS namespace, a page with a live preview, and the first user of composeFrame: huge anti-aliased digits the pattern shows through (typefaces rasterised offline by toolchain/build_clock_glyphs.py, several faces), with what fills the digits and what surrounds them chosen separately. Owns the time zone as a POSIX string with its DST rule; the core keeps the time (src/core_clock.h), this shows it. |
ble/ |
Wi-Fi provisioning from a phone over Improv-BLE | In no edition. The lifecycle works on hardware (advertise only while the panel cannot join, release the controller's memory after) but linking it costs ~12 KB of internal RAM and the one phone it was tried with never listed the panel. Opt-in for whoever finishes it; the header records what was measured. |
PF_FEATURES_NONE leaves the bare core: 1,094,813 B flash and 82,068 B of
static RAM, against 1,412,457 and 92,920 with the five features of 2026-08 loaded. The sketch is
byte-identical either way.
That gap buys the person holding the panel almost nothing, which is why
nothing was ever removed from the core. Flash sits at 45 % of the partition
even with the five features of 2026-08 loaded. The ceiling on a loadable .pfm — the largest
contiguous block — is 73,716 B with everything and the same 73,716 B in the
audio bundle, three features out. Only the bare PF_FEATURES_NONE build moves
it, to 92,148 B, and that is a compile flag rather than a firmware anybody
would ship. 18 KB more room, in a build with no shows, no MQTT, no weather, no
OSC and no sound, on top of a ceiling already two and a half times the
largest module anyone has built (29 KB; the 42-pattern community library tops
out at 17.5 KB). (All three read once, ~80 s after a reboot — heapLargest
decays under HTTP traffic, so repeated polling reports a lower number.) A named firmware exists to carry what the default cannot,
not to be smaller — see RFC §2.13 for the whole measurement.
See ../../bundles/README.md.
Recorded because it is the evidence behind the hook list, and because the same shape keeps recurring.
A second port is what tells you the interface was designed rather than
fitted to the first one. The show player fit perfectly and looked like
proof. Weather then needed two hooks it had never asked for: fillInput,
and chromeVisible on the frame — which in the sketch had been four
separate globals a feature could not see and should not have to. MQTT
added four more, audio three. The list stopped growing at four ports,
which is the only reason to believe it is close to complete.
Where the boundary actually is. The RFC said the device's own UI was out of scope for features, and audio walked straight into it: a row on the NETWORK screen and a knob that turns it off. The resolution was not to give features the menu, but to let the menu describe them — a feature says its short name and whether it is on, and the core renders the row. The core still owns its UI; it just no longer knows what audio is.
Infrastructure hides inside whichever feature needed it first. Four times now, and always found the same way — by trying to remove something:
| what | was living in | moved to |
|---|---|---|
| the console web server | core_audio_ws.h |
src/core_http.h |
| the absolute parameter bus | core_mqtt.h |
src/core_bus.h |
| local wall time (NTP, timezone) | core_weather.h |
src/core_clock.h |
| the banner (text on the panel) | core_mqtt.h |
src/core_banner.h |
The last two are the clearest. A show cue displayed its banner by calling
PatternflowMqtt::applyHeldMessage(), so putting text on the panel
required a broker client to exist; and the night/wake scheduler asked
weather what time it was, so sequences depended on weather for a clock.
Neither "show some text" nor "what time is it" is a feature question.
The fifth port is what found the gap. Four features ported cleanly and
that looked like proof; OSC did not, and that is the useful part. All four
earlier features spoke in names and booleans, so observeFrame handing over a
pattern name was enough. OSC publishes /patternflow/pattern/index and an
app-mode integer, both fixed in a released wire specification that hosts are
built against — the index cannot be quietly swapped for the name. So
PFFeatureFrame gained two fields and observeFrame takes the frame.
A hook list stops being a guess only when something nobody had in mind when it was written is pushed through it.
Moving a feature out is not finished when it compiles. OSC leaving found
three places the interface had not been told: a preset hardcoded from another
feature's directory, a pattern counter that counted entries it would never stop
on, and a status row reporting a feature the build did not have. The
compiler helps with none of them. caps is the only mechanism that scales,
and anything hardcoded beside it will drift.
A feature's file set is not obvious from its name. core_library_http.h
read as core until the compiler disagreed: it installs .pfs files from a
FlowLocal host, so it belongs with sequences.