Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8a545b7
docs(flash): one line on drivers, not a paragraph
engmung Aug 14, 2026
9b6255a
feat(firmware): absolute param bus — Director can pin a look, hands c…
engmung Aug 15, 2026
16b289b
feat(web): every pattern the site emits is absolute-ready from birth
engmung Aug 15, 2026
d096914
feat: the whole catalog goes absolute-ready — presets, Basics pack, D…
engmung Aug 15, 2026
55e2bc6
feat(firmware): the panel plays shows by itself — .pfs tables, /show,…
engmung Aug 15, 2026
7ebd76f
feat(web): a deck can carry its performance — JSON in, .pfs out, zip …
engmung Aug 15, 2026
3e04e4f
fix(firmware): show buffers move to PSRAM — 19 KB of .bss was a boot …
engmung Aug 15, 2026
adb4115
fix(firmware): a finished show hands the knobs back
engmung Aug 15, 2026
bd7b613
Revert "fix(firmware): a finished show hands the knobs back"
engmung Aug 15, 2026
b9f77a0
Revert "fix(firmware): show buffers move to PSRAM — 19 KB of .bss was…
engmung Aug 15, 2026
61ef25e
Revert "feat(firmware): the panel plays shows by itself — .pfs tables…
engmung Aug 15, 2026
f9531dd
docs: firmware hands back to Simone — dev keeps the web side and the …
engmung Aug 15, 2026
a77c35e
feat(web): performances ride the port rails, and the heart moves to t…
engmung Aug 15, 2026
101a151
fix(web): publishing takes the .pfs too, and the card marks stop drif…
engmung Aug 15, 2026
d96d379
fix(web): the frame chip stops announcing the stock panel
engmung Aug 15, 2026
b497e08
fix: converting a pattern stops meaning "needs the new firmware"
engmung Aug 15, 2026
edaef38
docs: the readme leads with why, then the three ways in
engmung Aug 15, 2026
6b0cf16
test(web): the deck pack's performance decoration was never once exec…
engmung Aug 15, 2026
7d33bee
docs: the readme borrows the campaign's DAW shot and pattern wall
engmung Aug 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion BUILD_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ No installation required — desktop **Chrome or Edge** only (Web Serial; Firefo
>
> The picker should now offer a line like `USB JTAG/serial debug unit (COM4) – Paired`. The number depends on which USB port you used.
>
> Still nothing? On this port it is almost always the **cable** — a charge-only USB-C cable enumerates nothing at all. The ESP32-S3 drives its native port from a USB-Serial/JTAG controller built into the chip, so there is no bridge chip and **no CP2102 / CH34x driver to install**; Windows 10 and later, macOS and Linux all recognise it on their own. The driver links on the flasher's "No port selected" screen are for the *right-hand* `UART` port (§8.2), and chasing them here is a dead end.
> Still nothing? It is almost always the cable — a charge-only USB-C one enumerates nothing at all. **There is no driver to install on this port**: the ESP32-S3 handles USB itself, so the CP2102 / CH34x links on the flasher's troubleshooting screen do not apply here.

> 📶 **Changing Wi-Fi later.** The network you set during flashing is **saved on the device and reused on every boot** — it stays until you overwrite it. To move Patternflow to a different Wi-Fi, either **re-flash from the browser** (you'll set the new network during Improv provisioning), or in Arduino IDE do a **full erase** (Tools → *Erase All Flash Before Sketch Upload* → *Enabled*) and re-upload. A plain re-upload does **not** clear the stored credentials.

Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to Patternflow will be documented in this file.

## [Unreleased]

Groundwork for timed performances, built to meet **Simone Majocchi's ([@SimonePDA](https://github.com/SimonePDA)) performance-director work** — he is continuing the firmware side (the on-device show player and scheduler live on the `fw/performance-director` branch awaiting his delivery); this tree carries the web side and the shared foundations, **releasing together with that firmware**.

### Added
- **Performances live under patterns**, on the same social rails as firmware ports: anyone records a timed knob ride in the Director tool and publishes the Save-JSON on the pattern's page — live immediately, credited to the recorder, with the author's own recording (or their pin) deciding which one represents the pattern. Each recording downloads as the editable `.json` or the packed `.pfs` the panel's player reads.
- **Like from the wall.** Every pattern card grew a heart beside its add-to-deck button — the count updates in place, and signed-out visitors get the sign-in modal at the moment they click, same rule as everywhere else.
- **Packs carry performances.** A deck owner can attach a Director performance JSON to their deck; the pack zip then includes `performance.json` (the editable source) and the encoded `.pfs` show table. Today's firmware simply ignores the `.pfs` on install — the pack stays a normal pattern pack — and the performance firmware picks it up the day it ships. The server-side PFST encoder is byte-identical to the Director's own saves.
- **Absolute parameter bus (0..1000), prepared.** MQTT `​<prefix>/param/1..4` can pin any knob of an absolute-ready pattern to an exact value; physical encoder motion releases the hold, and plain deltas keep working exactly as before. Patterns opt in through one `PFParams::apply` line per knob — every preset, the Basics pack, and all convertible community headers are converted, and everything the site generates is absolute-ready from birth. `/mqtt` grew channel presets (Broadcast / Ch 1–4 / Live) with retained per-channel snapshots, and a **Director mode** that points the panel at a local authoring broker without disturbing the saved one.

### Changed
- **Module ABI descriptor is now 2** (the appended absolute-param fields). The loader accepts 1 and 2, so every existing `.pfm` keeps loading; **pre-absolute firmware refuses new modules cleanly** instead of misreading them. This is the gate that ties the release to the performance firmware: it ships when devices can update to a loader that accepts it.
- **MQTT retention now follows the show policy**: knob/pattern topics publish non-retained; the broadcast banner and channel snapshots are the retained exceptions.

## [3.4.0] - 2026-08-12

Patterns leave the firmware and live on the filesystem, so a board ships nearly empty — and a pack ships with it. **Hardware unchanged**; v3.0 board and case carry over as-is.
Expand Down
214 changes: 102 additions & 112 deletions README.md

Large diffs are not rendered by default.

Binary file added docs/media/community-builds.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/media/community-library.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/media/daw-ableton.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 31 additions & 2 deletions firmware/patternflow/abi/pf_abi.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,39 @@
// - firmware (src/core_module_api.h fills PFHostAPI)
// - modules (abi/pf_module.h wraps it in the familiar PF* namespaces)
//
// Bump PF_ABI_VERSION whenever any struct layout, field order, or calling
// convention below changes. The loader refuses modules whose version differs.
// Versioning, two numbers with different jobs:
//
// PF_ABI_VERSION the HOST API generation, passed to the module in
// PFHostAPI.abi_version. Frozen at 1: every module
// ever shipped checks it with `!=`, so raising it
// would refuse the entire installed catalog.
// PF_ABI_MODULE_VERSION what a freshly built module stamps into its
// descriptor. Raised to 2 when the absolute-param
// fields were APPENDED to PFInputFrame — the layout
// prefix is unchanged, so the new host still runs
// v1 modules, but a v2 module on a pre-absolute
// host would read past the host's InputFrame and
// see garbage in paramAbsoluteActive[]. The old
// loader's exact-match check (`!= 1`) is what turns
// that silent corruption into a clean refusal.
//
// The loader accepts descriptor versions PF_ABI_VERSION..PF_ABI_MODULE_VERSION.
// Only ever APPEND fields to these structs; reordering or resizing existing
// members breaks the v1 prefix contract and needs a real generation bump.
#pragma once

#include <stdarg.h>
#include <stddef.h>
#include <stdint.h>

#define PF_ABI_VERSION 1
// Overridable per build (-DPF_ABI_MODULE_VERSION=1): a module whose source
// never touches the absolute-param fields does not need the newer host, so
// build_module.py stamps it 1 and it keeps installing on pre-absolute
// firmware. Only converted patterns claim 2 and accept being refused there.
#ifndef PF_ABI_MODULE_VERSION
#define PF_ABI_MODULE_VERSION 2
#endif

// Mirrors src/core_encoders.h::InputFrame exactly. Both sides are built with
// the same GCC for the same target, so layout matches field-for-field and
Expand All @@ -25,6 +49,11 @@ typedef struct PFInputFrame {
uint32_t now;
bool knobAudioActive[4];
float knobAudioValue[4];
// Absolute bus 0..1000 (Director / Show manager). Appended — older
// modules that never read these fields keep working without a rebuild.
// Modules that DO read them must be built as PF_ABI_MODULE_VERSION 2.
bool paramAbsoluteActive[4];
uint16_t paramAbsolute[4];
} PFInputFrame;

// Services the firmware provides to a loaded module.
Expand Down
10 changes: 7 additions & 3 deletions firmware/patternflow/abi/pf_module.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
#include <string.h>

#include "pf_abi.h"
#include "pf_params.h"

#ifndef PF_PANEL_W
#error "PF_PANEL_W not defined - build modules with -DPF_PANEL_W=<panel width>"
Expand Down Expand Up @@ -269,13 +270,16 @@ inline void init() {
void pf__update_thunk(float dt, const PFInputFrame* input) { \
ns::update(dt, *input); \
} \
/* Descriptor carries PF_ABI_MODULE_VERSION: this build reads the appended \
absolute-param fields, and stamping 2 is what makes a pre-absolute \
loader (exact-match on 1) refuse it instead of feeding it garbage. */ \
PFPatternModule pf__descriptor = { \
PF_ABI_VERSION, PF_PANEL_W, PF_PANEL_H, nullptr, nullptr, \
nullptr, nullptr, nullptr, \
PF_ABI_MODULE_VERSION, PF_PANEL_W, PF_PANEL_H, nullptr, nullptr, \
nullptr, nullptr, nullptr, \
}; \
} \
extern "C" const PFPatternModule* pf_module_entry(const PFHostAPI* api) { \
if (!api || api->abi_version != PF_ABI_VERSION) return nullptr; \
if (!api || api->abi_version < PF_ABI_VERSION) return nullptr; \
if (api->panel_w != PF_PANEL_W || api->panel_h != PF_PANEL_H) return nullptr; \
PFHost::api = api; \
pf__descriptor.name = ns::NAME; \
Expand Down
183 changes: 183 additions & 0 deletions firmware/patternflow/abi/pf_params.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Patternflow absolute param bus helpers (0..1000 wire scale).
//
// Shared by firmware presets and loadable modules. Priority per channel:
// 1. paramAbsoluteActive → lerp(min, max, v/1000)
// 2. knobAudioActive → lerp(min, max, 0..1) (Weather / audio)
// 3. knobDeltas → integrate with step
//
// ── One source, two builds ───────────────────────────────────────────────
// Reading paramAbsoluteActive on a host that predates those fields means
// reading past the end of its InputFrame, so a converted pattern cannot
// simply be installed on old firmware. Rather than fork the catalogue, the
// absolute tier compiles out when a module is built for the older host
// (-DPF_ABI_MODULE_VERSION=1): the same converted source then behaves as it
// always did — audio, then knob deltas — and stamps a descriptor those
// loaders accept. Build it for the newer host and the absolute tier is there.
//
// So converting a pattern is never a decision about which firmware its
// author's audience runs; that is the build's business, not the source's.
//
// License: MIT
#pragma once

#include <math.h>
#include <stdint.h>

#include "pf_abi.h"

// PF_ABI_MODULE_VERSION >= 2 means "this build's host has the absolute
// fields". pf_abi.h defaults it to 2 (firmware, and any module build that
// did not ask otherwise); build_module.py passes 1 when targeting the older
// host. Kept as one named test so no helper below can drift from the rest.
#if PF_ABI_MODULE_VERSION >= 2
#define PF_PARAMS_HAS_ABSOLUTE 1
#else
#define PF_PARAMS_HAS_ABSOLUTE 0
#endif

namespace PFParams {

inline float unit1000(uint16_t v) {
if (v > 1000) v = 1000;
return (float)v / 1000.0f;
}

inline float lerp(float lo, float hi, float u) {
return lo + (hi - lo) * u;
}

inline float clampf(float v, float lo, float hi) {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}

// Float param with linear absolute / audio / delta paths.
template <typename Frame>
inline void apply(const Frame& input, int i, float* param, float lo, float hi, float step) {
if (!param || i < 0 || i > 3) return;
#if PF_PARAMS_HAS_ABSOLUTE
if (input.paramAbsoluteActive[i]) {
*param = clampf(lerp(lo, hi, unit1000(input.paramAbsolute[i])), lo, hi);
return;
}
#endif
if (input.knobAudioActive[i]) {
*param = clampf(lerp(lo, hi, input.knobAudioValue[i]), lo, hi);
return;
}
if (input.knobDeltas[i] != 0) {
*param = clampf(*param + (float)input.knobDeltas[i] * step, lo, hi);
}
}

// Integer param. When wrap=true, values cycle through [lo, hi] inclusive.
template <typename Frame>
inline void applyInt(const Frame& input, int i, int* param, int lo, int hi, int step,
bool wrap = false) {
if (!param || i < 0 || i > 3) return;
#if PF_PARAMS_HAS_ABSOLUTE
if (input.paramAbsoluteActive[i]) {
float u = unit1000(input.paramAbsolute[i]);
if (wrap) {
// 0..1000 maps across the full wrap range (e.g. hue 0..359).
int span = hi - lo + 1;
if (span <= 0) {
*param = lo;
} else {
int v = lo + (int)(u * (float)span);
if (v > hi) v = hi;
*param = v;
}
} else {
*param = lo + (int)(u * (float)(hi - lo) + 0.5f);
if (*param < lo) *param = lo;
if (*param > hi) *param = hi;
}
return;
}
#endif
if (input.knobAudioActive[i]) {
float u = input.knobAudioValue[i];
if (u < 0.0f) u = 0.0f;
if (u > 1.0f) u = 1.0f;
if (wrap) {
int span = hi - lo + 1;
int v = lo + (int)(u * (float)span);
if (v > hi) v = hi;
*param = v;
} else {
*param = lo + (int)(u * (float)(hi - lo) + 0.5f);
if (*param < lo) *param = lo;
if (*param > hi) *param = hi;
}
return;
}
if (input.knobDeltas[i] == 0) return;
int next = *param + input.knobDeltas[i] * step;
if (wrap) {
int span = hi - lo + 1;
if (span <= 0) {
*param = lo;
return;
}
next = (next - lo) % span;
if (next < 0) next += span;
*param = next + lo;
} else {
if (next < lo) next = lo;
if (next > hi) next = hi;
*param = next;
}
}

// Discrete index 0..count-1 (pattern modes, preset pickers).
template <typename Frame>
inline void applyIndex(const Frame& input, int i, int* param, int count, int step = 1) {
if (!param || count <= 0 || i < 0 || i > 3) return;
#if PF_PARAMS_HAS_ABSOLUTE
if (input.paramAbsoluteActive[i]) {
int idx = (int)(unit1000(input.paramAbsolute[i]) * (float)count);
if (idx >= count) idx = count - 1;
*param = idx;
return;
}
#endif
if (input.knobAudioActive[i]) {
int idx = (int)(input.knobAudioValue[i] * (float)count);
if (idx >= count) idx = count - 1;
if (idx < 0) idx = 0;
*param = idx;
return;
}
if (input.knobDeltas[i] == 0) return;
int next = *param + input.knobDeltas[i] * step;
next %= count;
if (next < 0) next += count;
*param = next;
}

// Unit param 0..1 with wrap on delta (typical hueBase / phase knobs).
template <typename Frame>
inline void applyUnit(const Frame& input, int i, float* param, float step = 0.05f) {
if (!param || i < 0 || i > 3) return;
#if PF_PARAMS_HAS_ABSOLUTE
if (input.paramAbsoluteActive[i]) {
*param = unit1000(input.paramAbsolute[i]);
return;
}
#endif
if (input.knobAudioActive[i]) {
float u = input.knobAudioValue[i];
if (u < 0.0f) u = 0.0f;
if (u > 1.0f) u = 1.0f;
*param = u;
return;
}
if (input.knobDeltas[i] == 0) return;
*param += (float)input.knobDeltas[i] * step;
*param = *param - floorf(*param);
if (*param < 0.0f) *param += 1.0f;
}

} // namespace PFParams
31 changes: 30 additions & 1 deletion firmware/patternflow/pattern_registry.h
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,14 @@ struct PatternEntry {
// The four members above are meaningless for a module: its code is not in
// firmware.bin, so the loader dispatches instead.
const char* modulePath;
// Pattern maps the MQTT absolute 0..1000 bus via PFParams (Director /
// Show manager). Presets declare it as ns::ABSOLUTE_READY; modules carry
// it in their sidecar .json.
bool absoluteReady;
};

#define PATTERN_ENTRY(ns) \
{ ns::NAME, ns::KNOB_LABELS, ns::setup, ns::update, ns::draw, nullptr }
{ ns::NAME, ns::KNOB_LABELS, ns::setup, ns::update, ns::draw, nullptr, ns::ABSOLUTE_READY }

// To add a pattern:
// - Module (the usual way now): build a .pfm and upload it — no rebuild.
Expand Down Expand Up @@ -220,6 +224,30 @@ inline void readSidecarName(const char* modulePath, char* out, size_t outSize) {
snprintf(out, outSize, "%s", json.substring(open + 1, close).c_str());
}

// Same sidecar, different key: whether the module was built against the
// absolute-param helpers. Missing sidecar or missing key both mean "no" —
// every module built before the bus existed is delta-only by definition.
inline bool readSidecarAbsoluteReady(const char* modulePath) {
char jsonPath[MODULE_PATH_BYTES];
snprintf(jsonPath, sizeof(jsonPath), "%s", modulePath);
char* extension = strrchr(jsonPath, '.');
if (!extension) return false;
snprintf(extension, sizeof(jsonPath) - (extension - jsonPath), ".json");

File metadata = FFat.open(jsonPath, FILE_READ);
if (!metadata) return false;
String json = metadata.readString();
metadata.close();

int key = json.indexOf("\"absoluteReady\"");
if (key < 0) return false;
int colon = json.indexOf(':', key + 15);
if (colon < 0) return false;
String tail = json.substring(colon + 1);
tail.trim();
return tail.startsWith("true");
}

// Mount the partition the presets never needed. Label "ffat" is what the
// shipped partition table calls it; passing the wrong label mounts nothing and
// looks exactly like an empty filesystem.
Expand Down Expand Up @@ -386,6 +414,7 @@ inline void buildPatternList() {
for (int i = 0; i < numModules; i++) {
patterns[NUM_PATTERNS++] = {
moduleNames[i], MODULE_KNOB_LABELS, nullptr, nullptr, nullptr, modulePaths[i],
readSidecarAbsoluteReady(modulePaths[i]),
};
}
}
Expand Down
Loading
Loading