How Patternflow is put together, and how to build your own firmware on it.
Read this before writing a feature or cutting a firmware. It is the reasoning; the two references it points at are the mechanics.
Patternflow is a device that loads interactive patterns and runs them under four knobs. That sentence decides everything below it, and it is the only test worth applying.
What that sentence names is not a feature and never composes out: the pattern
loader, the four encoders, the panel driver, Wi-Fi (its own hotspot
included), /update, sleep, the console. Those are the device. The default
build carries no features at all, and a panel running it does the whole of
what Patternflow is for.
Everything else — sequences, MQTT, OSC, weather, audio — is a way of driving that device from somewhere else. Real, wanted, first-class, and not the thing itself. Each belongs to an edition somebody chooses.
The line is easy to draw somewhere flattering instead. An earlier version of this document used a different test — does this need infrastructure outside the room — which is a fine question about what is separable and a bad one about what is core: it kept OSC and the show player in a build called standard for no better reason than that they need nothing running to work. Cheap is not the same as central.
The measurement agrees with the definition, which is the part worth trusting.
On hardware, the largest contiguous block a loadable .pfm can claim:
| build | largest free block |
|---|---|
| default — patterns and knobs | 92,148 |
| performance — sequences, MQTT, weather | 77,812 |
| audio — OSC, browser audio, microphone | 69,620 |
The build that matches "this device runs patterns" is also the build that gives patterns the most room to be.
A feature is a directory in this repository implementing one capability behind a fixed interface — the show player, MQTT, weather, OSC, browser audio, the on-board microphone. It never edits a core file. It registers itself in one list and the core dispatches to it through thirteen hooks.
A composition is a choice of which features compile into a build. It is two files, and no code: one saying which features, one saying what the build calls itself and any settings it needs different.
An edition is a composition somebody published — a firmware with a name, a version of its own, and a card on patternflow.work/editions that installs it in one click. Three are on the shelf:
| carries | whose | |
|---|---|---|
| Patternflow | nothing — the device itself | the product |
| Audio | OSC, MIDI, browser audio, the on-board microphone | SeungHun Lee |
| Performance | sequences, MQTT, FlowLocal, the Director, weather, the clock | Simone Majocchi |
The clock and USB MIDI also build as bundles of their own, with a try-out image each on the features page and no card (below).
The word "addon" is retired. It suggested something optional or third-party, and none of these are: they are first-class capabilities that happen to live behind an interface.
| word | in the tree | older spelling still in use, and why |
|---|---|---|
| feature | firmware/patternflow/features/<name>/ |
addon — shimmed for out-of-tree compositions until they migrate (see The rename below) |
| composition | firmware/bundles/<name>/ — the two files |
the directory is called bundles/; a bundle is a composition's folder, nothing more |
| edition | the shelf at patternflow.work/editions | variant on the wire: PF_VARIANT / PF_VARIANT_VERSION in overrides.h, the variant field in /api/status, /api/variant-bin/ on the site, VARIANT_BIN_DIR on the host. Those are contracts other software reads, so they keep their names; /variants redirects to /editions |
Prose written from here on says feature, composition and edition. A word from the right-hand column in new prose is a mistake unless it names the wire.
Start here, because the structure is easy to misread and the misreading is costly.
This says nothing about which features matter, who is important, or whose work is central. There is no tier of features, no promotion, no demotion. MQTT is not less than patterns because it can be composed out; the show player is not less than OSC because a build might not carry it. Every feature sits in the same tree, under the same interface, with the same standing. A composition is a build-time choice, not a verdict.
The shelf does have tiers — official and community — and even that is not about standing. It answers one question: who do you ask when it breaks? A firmware built here gets compiled against every core change before that change lands. A firmware built elsewhere is its author's to keep working. Different maintenance obligations, not different worth.
Nothing here decides who owns anything. It decides what is in the room when you compile.
The reason is development ergonomics, and specifically the shape development has now.
Most edits to this firmware are made by an agent. Not "assisted by" — made by. It reads what it is shown, changes what it was asked to change, and does not remember what it learned yesterday. That is a fine way to work and it is how this project moves as fast as it does, but it changes which safeguards are worth anything.
Consider the two ways to keep an audio change from breaking MQTT:
| "Be careful not to touch MQTT." | An instruction. Depends on the reader having read the MQTT code, held it in mind, and noticed the interaction. Costs nothing to say and guarantees nothing. Degrades with every file added to the tree, and does not survive a fresh context at all. |
| MQTT is not in this build. | A fact. The compiler enforces it. It does not degrade, it does not depend on anyone's attention, and it is exactly as true on the hundredth edit as the first. |
The second is worth having and the first is not, and the gap between them grows every time the codebase grows or the person changes.
Working on audio means building the audio edition: OSC, browser audio, the on-board microphone. MQTT, the show player and weather are not compiled, so nothing done to audio can reach them. The change under review is smaller, the thing to hold in mind is smaller, and the guarantee is mechanical instead of attentional.
It is not mainly about memory, and the numbers are more interesting than either slogan. Dropping some features moves nothing: the audio edition sheds 278 KB of flash and lands on the same 73,716-byte ceiling the full build had. Dropping all of them does move it, to 92,148 — but that is the default doing the one job the device exists for, not a saving anyone engineered. Nobody should sell composition as a memory trick. See RFC §2.13 for the controlled measurement and the procedure it needs.
It does not protect the core. A change to patternflow.ino, the ABI, or
the bus reaches every edition, and composing features out does nothing about
it. The worst bug this project has shipped was exactly that shape — one missing
line in the sketch froze the show player, MQTT, weather and the audio websocket
at once, in every build. Editions would not have caught it. Nothing about this
structure makes core changes cheap, and they should still be made carefully and
measured on hardware.
What it buys is the ordinary case: work inside one feature, which is most work. There, the blast radius stops at the build's edge, and it stops for a reason a machine can check.
The obvious objection is that a feature you have to install is a feature the product does not really have.
This project already answers that, with its best work. Community patterns are not in the firmware. A panel arrives with a loader and a catalogue; the patterns arrive from the shelf, one click, over Wi-Fi. Nobody has ever suggested that a pattern somebody installed is therefore not Patternflow — the 42-pattern library is the liveliest part of the project and not one byte of it ships in the image.
An edition takes the same route through the same shelf, with the same click, and your patterns, networks and settings all survive it. If installing demotes MQTT, it demotes every pattern anyone has written. It demotes neither.
Choosing an edition is choosing what this panel is for right now — sound, or a show, or a room full of sensors — and changing your mind costs one click.
These are the promises an edition inherits, and the ones that make switching safe. Break any of them and the firmware is a fork rather than an edition.
| The partition table | Byte-identical in every release since v3.1.0. Two 3 MB app slots, 10 MB of FFat for patterns, NVS for settings. A firmware that moves ffat erases every pattern on the board. |
/update, in and out |
Every edition can be entered and left over Wi-Fi. Removing the way out delists it. |
The .pfm ABI |
Frozen. Fields are appended, never reordered or reinterpreted; old modules keep playing. |
The .pfs show format |
Frozen the same way, version byte and all. |
| Shared NVS keys | Wi-Fi credentials, brightness, selected pattern. Read and write the existing keys so nobody re-provisions after switching; never invent new keys inside a core namespace — yours go in your own. |
/api/status reports variant and caps |
So the site, the lab and any integration can ask what this build can do instead of assuming. |
The formats are the guarantee that actually matters to somebody performing: not that the firmware never changes, but that the files keep opening. See RFC §2.14.
"If the core keeps improving, will the things built against it keep working?" - the right worry, so here is the contract, strongest first. Everything below has an enforcer; a promise kept by memory is the kind that was broken three times before the boundary checker existed.
1. The module ABI (firmware/patternflow/abi/) - binary, strongest.
Consumed by installed .pfm files that never recompile. Frozen field
layout, append-only growth, an explicit version handshake
(PF_ABI_VERSION 1 is frozen; modules stamp PF_ABI_MODULE_VERSION; the
loader accepts the range), and since 2026-08-30 a CI lock: abi.sums pins
every header's hash and check_abi_freeze.py fails any drift that was not
deliberately re-pinned in the same commit.
2. The hook interface (features/pf_feature.h) - compile-time. For
feature code that recompiles against a checkout, in-tree or out. Widening
is tail-append only (the rule and its reason are written on the struct);
the compiler surfaces every break at build, and build.sh all makes sure
"builds" means all three compositions. The two composition filenames and
their macros are the out-of-tree seam - renamed spellings are shimmed, and
shims get deleted only after their consumers migrate.
3. The wire (rest-api.md, osc-spec.md,
audio-ws-spec.md) - runtime. Integrations build
against the spec files, not the firmware source. Growth is additive; each
spec carries its own compatibility rule (probe caps before assuming an
endpoint; unknown WebSocket prefixes are ignored; new OSC addresses do not
move old ones) and its own version history.
Explicitly not promised: everything else. Core file layout, namespaces,
internal helpers, what a screen draws, which serial lines print - all of it
may change without notice, and the addons/ -> features/ rename is the
standing example: forty files moved, and nothing that speaks any of the
three contracts above noticed. That is the shape of the deal - the inside
stays free precisely because the edges are locked.
Full reference, including all thirteen hooks and what proved each one:
firmware/patternflow/features/README.md.
The shape, briefly. A feature is a directory with a descriptor:
inline const PFFeature descriptor = {
"weather", // name
"weather", // cap — what /api/status advertises, and what the nav gates on
setup, // boot, before Wi-Fi
onNetwork, // Wi-Fi up: register HTTP routes here
loop, // every frame — MUST NOT block
nullptr, // observeFrame — the finished frame, for features that mirror
fillInput, // drive a lane before the pattern sees the frame
/* ... */
};Four rules that are not negotiable:
- 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. PSRAM is plentiful but shared: patterns that have run stay parked in it (src/core_module_resident.h), and they give way to patterns, not to you. While any is parked, the core keepsPF_MODULE_RESIDENT_HEADROOM(512 KB) free whenever one is parked and after every allocation a pattern makes, and evicts parked ones to do it, but your own allocation evicts nothing: it gets what is free, which can then be as little as those 512 KB less whatever other features and the console took from them. Yoursetup()runs at boot, before anything is parked, so take big buffers there, not on first use hours later. - Never edit a core file. Your entire diff is additions. That is what makes taking a core update a file copy rather than a merge, and it is the whole reason the seam exists.
- Your settings live in your own NVS namespace.
Servers and tasks of your own are fine — the browser-audio feature runs its own websocket on port 81 — as long as the loop hook stays quick.
Full reference: firmware/bundles/README.md.
Two files under firmware/bundles/<name>/:
// features_local.h — which features compile in, and in what order
#include "osc/feature_osc.h"
#include "audio/feature_audio.h"
#include "audio_in/feature_audio_in.h"
#include "midi/feature_midi.h"
#define PF_FEATURE_LIST \
&PFFeatureOsc::descriptor, \
&PFFeatureAudio::descriptor, \
&PFFeatureAudioIn::descriptor, \
&PFFeatureMidi::descriptor// overrides.h — what it calls itself, and anything it needs set differently
#define PF_VARIANT "audio"
#define PF_VARIANT_VERSION "v0.7.0" // yours, moves at your pace
#define PF_AUDIO_IN_DRIVES_KNOBS 1 // the microphone drives the knobs hereThat is the Audio edition's pair with the comments taken out (v0.7.0 when
this was written). It pinned PF_WIFI_TX_POWER too until the core went to
full power, and dropped it in v0.7.0 — which is why every setting in an
overrides.h carries its reason: one copied without it stays after the reason
has gone.
A third file, env, is optional: one line naming the PlatformIO env to build
in, for the rare edition whose difference is a build flag rather than a
setting. The MIDI bundle is the one so far — its USB port has to be the OTG
controller for a MIDI device to exist, and the Arduino core decides that at
compile time (ARDUINO_USB_MODE). Everything else about it is the two files
above; build.sh and shelf.sh read the env name and look for the outputs
under it.
Then:
./firmware/bundles/build.sh audio # build it
./firmware/bundles/build.sh audio flash pf.local # and push it to a panel
./firmware/bundles/build.sh all # every composition, provenall builds default, audio, performance, clock and midi, then scans each binary for one
marker string per feature — a literal that lives only in that feature's
sources. An edition must contain its own features' markers and none of the
others'. That is the composition checked in the shipped bytes: features.h
catches a misspelled macro, this catches the right macro building the wrong
thing. Run it before pushing anything that touches the core; it is the rule
"a core change is tested against every composition" as one command.
The boundary itself is mechanical too: firmware/toolchain/check_boundaries.py
(run in CI on every firmware change) fails the build if a core file references
a feature namespace, includes from the feature tree, or branches on a
feature's flags. The rule lived in comments first and was violated three times
before it grew teeth; on its first run the checker found a fourth, so the
lesson is written down here: a rule an agent must remember is a rule that
will drift — put it where the build fails.
overrides.h is included before any default in config.h, so it reaches every
#ifndef-guarded setting in the tree — panel clock, transmit power, brightness
cap, the lot. features_local.h sits beside the core's own list, which steps
aside when it finds one. Neither file is a core file, and the build script
puts both back the way it found them.
Order in PF_FEATURE_LIST is dispatch order, and it matters where features
compete: one that CLAIMS the pattern (a show) should come after ones that only
ASK (a remote picker), or the picker never gets a turn.
Two traps worth knowing before you cut one, both found by cutting these:
- Libraries are named, not discovered. PlatformIO's dependency finder
scans includes and cannot evaluate the
__has_includeyour composition arrives through, so a feature's library disappears the moment that feature leaves the default build. Anything a feature needs belongs inlib_deps. WebSockets went that way when audio left; HTTPClient followed when weather did. PF_FEATURE_PRESETScuts both ways. Defining it as nothing is how a build without the show player keeps the scheduler's hiddenBlackpattern out of the carousel. In a build with the show player it deletes a pattern the scheduler calls by name, and the build fails on'Black' has not been declared. Copying another edition'soverrides.his how you get there.
An edition's version is its own and moves at whatever pace suits it — 0.2, 0.3,
5.0. It is reported in /api/status as variantVersion, worn as a badge on
every console page, and shown on the shelf card. It has nothing to do with the
core version, which is reported separately.
The core version an edition was built against is worth stating in its release notes, because that is what makes a build reproducible later.
The shelf has two tiers, and the difference is who to ask when it breaks, not quality.
A bundle in the tree is not on the shelf by itself. firmware/bundles/clock
and firmware/bundles/midi are compositions CI keeps compiling, with no card:
the clock is a feature, and the MIDI bundle proves the USB-OTG build. What
each has instead is a try-out image on the features page - a frozen copy,
installed the way the shelf installs but never bumped with the core, and
named in web/src/app/features/features-data.ts rather than by a card. An
edition is listed when its maintainer cuts it (release.py edition, see
RELEASING.md) and a card names the image.
Official — built from this repository. A core change has to compile against it before that change lands, so it cannot silently rot. Its image is served from patternflow.work, which is also what makes one-click install possible: the board cannot fetch over TLS, so the browser does the download, and that fetch needs a CORS header only a host we control can send.
Community — your repository, your releases, your issues, your binary. A
fork, and that is fine. Anything hosted elsewhere is downloaded by hand and
dropped on /update.
To be listed, either way:
- Keep the partition table. Never change it.
- Keep
/updateworking, in and out. - Report
variantand its version in/api/status. - Keep the
.pfmloader and ABI intact. - Share the core NVS keys; keep your own settings in your own namespace.
- Say plainly what it changes — one honest paragraph. If it raises the transmit power or needs a part soldered on, that belongs on the card, not in a footnote.
An edition earns a place on the shelf by carrying something the default cannot — a part that is not on the board yet, a setting that must not be universal, a build somebody needs pinned so a show behaves the same at the next gig. Rule 2 is the one that matters most: a firmware you cannot leave is a fork, not an edition.
The directory was firmware/patternflow/addons/ and the vocabulary was
"addon" until this document settled on "feature"; the tree now matches the
words. For a composition maintained OUT of this tree — two files copied over
a checkout — the old spellings still build: addons_local.h is accepted
beside features_local.h, PF_ADDON_LIST / PF_ADDONS_NONE map to the new
macros, the old addon_<name>.h descriptor headers exist as stubs, and
PFAddonOsc-style namespaces are aliases. The shims are marked in the tree
and get deleted once every out-of-tree bundle has migrated — copy the two
files into features/ (the directory is the one thing a shim cannot rename)
and swap the prefixes at your leisure.
One migration is real rather than spelling: a composition that carries the show player must now declare its night-face preset itself —
#define PF_FEATURE_PRESET_INCLUDE "show/preset_black.h"
#define PF_FEATURE_PRESETS PATTERN_ENTRY_HIDDEN(Black),— because the bare core no longer ships another feature's pattern by default.
Without those two lines the build stops at 'Black' has not been declared,
which is the compiler saying the same thing this paragraph does.
Every feature is in this repository. Nothing is moving out, and this is settled — see RFC §2.13.
The reason is not ceremony. In this tree, widening a hook breaks every feature
that used it in the compiler, in the same commit — which is exactly what
happened when observeFrame grew a parameter for OSC and MQTT failed to build
a second later. In somebody else's tree nothing happens, it rots quietly, and
the eventual reconvergence is worse than the merge that was being avoided.
A separate repository is where trees drift apart, not where they stop.
So: the code is together, the builds are separate. Those are different questions and this project answers them differently. Somebody else's firmware is still welcome — that is what the community tier is for — but the features that make this Patternflow live here.
Two things live near the tree without being in it, and both are on purpose:
features/ble/ (Improv over Bluetooth) is in the tree and in no edition,
because no phone showed it in its pick list; and the Wi-Fi setup portal is a
parked branch, feat/wifi-setup-portal, in no edition and not to be merged as
it stands — it predates the seam and edits the core. What that attempt learned
is in investigations/2026-08-the-wifi-portal-a-samsung-never-drew.md.
The need it was for — setting Wi-Fi up from a phone — is met in the core
since 3.10.5 by the panel's own hotspot (src/core_hotspot.h): the
device's Wi-Fi, so core rather than a feature.
| you want to | read |
|---|---|
| write a feature | features/README.md — the hooks, in full |
| cut an edition | bundles/README.md — the two files, in full |
| know why any of this | RFC §2.13, §2.14, §2.15 |
| talk to a panel | rest-api.md |
| write a pattern | PATTERN_GUIDE.md — the Pattern Lab route; firmware/CUSTOM_PATTERNS.md for the hands-on one |