Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
98b423c28This file is pinned to the selected release. Download the original to open it in your design tool.
// ═══════════════════════════════════════════════════════════
// PatternFlow — the device itself
//
// The panel, the four encoders, the pattern loader, Wi-Fi, sleep, /update and
// the web console. A build of this file with nothing else still does the thing
// Patternflow is for: load an interactive pattern and run it under four knobs.
//
// EVERY OTHER BUILD IN THIS REPOSITORY COMPILES THIS FILE UNCHANGED. Audio and
// Performance differ by two files in firmware/bundles/<name>/ and not one line
// here, so a change made for one edition lands in all of them. That is the
// whole point and also the whole risk. docs/EDITIONS.md is the reasoning;
// features/pf_feature.h is the interface; features/features.h is the composition.
//
// ── The rule ────────────────────────────────────────────────────────
//
// No feature is named in this file. Not in an #include, not in an #if, not in
// a string on the screen. Every feature reaches the core through the hooks in
// pf_feature.h, dispatched here as PFFeatures::something() that neither knows nor
// asks what is behind it. If you find yourself wanting `#if PF_OSC_ENABLED`
// here, the answer is a hook, or the feature does not belong in the core.
//
// The one thing this file DOES decide is where each hook fires in the frame.
// Which features run, and in what order, is the composition's business.
//
// ── If you are an AI agent editing this file ────────────────────────────
//
// You were probably asked to add or fix a feature, and this file is where the
// symptom showed up. That is not the same as this being where the fix goes.
//
// THIS FILE CHANGES FOR TWO REASONS ONLY: a bug in the device itself, or a
// real improvement to what the device is. A feature bends to fit the core.
// The core does not bend to fit a feature. If an edit here exists so that one
// feature can work, it is in the wrong file - put it behind a hook instead.
//
// The test takes one question: would this edit still be correct on a build
// that does not contain the feature you are working on? If the honest answer
// is "it would do nothing" or "it would be wrong", move it to features/.
//
// Three things that mislead agents in particular:
//
// - A name is not a feature. `oscInfoShowing` is the NETWORK screen's own
// flag and has had nothing to do with OSC for a long time. Do not follow
// a name into a conclusion about what the code does, and do not rename
// across this file to match a tidier mental model - that is a large diff
// with no behaviour change, and it buries the real one underneath.
//
// - The comments here explain WHY the code has its shape, and several are
// the only surviving record of a bug that took hardware to find. They
// are not clutter. Change the code and you update the comment; if you
// cannot tell whether a comment is still true, leave it and say so.
//
// - Compiling is not testing. Most of what has broken here broke silently
// on a build that had no feature to notice. Build all three editions.
//
// ── What has actually gone wrong here ─────────────────────────────
//
// Four real regressions, all of them invisible on the build that was in front
// of the person who caused them. Read these before editing:
//
// 1. `InputFrame input{}` in the main loop. The braces are load-bearing. The
// lane fields are written only by a feature that drives them, so without
// the value-initialisation a build that has no such feature reads stack
// garbage - and a garbage active-flag makes PFParams::apply return early,
// which ignores the physical knobs. Shipped once. Do not drop the braces.
//
// 2. PFFeatures::loop(frame) must be dispatched every frame. It went missing
// in v3.7.0 and every feature's loop silently stopped; nothing failed to
// compile, and the default build had no features to notice.
//
// 3. Screen text that names a feature. "OSC / AUD" was hard-coded under the
// NETWORK screen's toggle rows long after the rows themselves were built
// from the feature list, so two of the three editions drew an instruction
// for knobs that toggle nothing.
//
// 4. Widening a hook's signature. That is fine and the compiler will tell
// you: build all three editions (firmware/bundles/build.sh, then `audio`
// and `performance`) before you push. A hook change that compiles only
// against the default is not tested.
//
// License: MIT
// ═══════════════════════════════════════════════════════════
#include <Arduino.h>
#include <Preferences.h>
#include "config.h"
#include "src/core_display.h"
#include "src/core_ui_text.h"
#include "src/core_encoders.h"
#include "src/core_wifi.h"
#include "src/core_hotspot.h"
#include "src/core_improv.h"
#include "src/core_ota.h"
#include "src/core_home_http.h"
#include "features/pf_features.h"
#include "src/core_web_update.h"
// Reads the demand the display driver measured while blitting, so it comes
// after core_display.h and before anything that sets brightness.
#include "src/core_power.h"
// Drives the panel and the CPU down for sleep mode. Needs core_display.h only,
// so it sits here rather than tangling with anything on the network side.
#include "src/core_sleep.h"
#include "src/core_banner.h"
#include "pattern_registry.h"
#include "src/core_thumbs.h"
// After the registry: the pattern manager serves and mutates that list.
#include "src/core_patterns_http.h"
#include "src/core_status_http.h"
#include "src/core_display_http.h"
#include "src/core_wifi_http.h"
#include "src/core_knobs_http.h"
#include "src/core_loop_sync.h"
#include "src/core_net_task.h"
// ── Device state ──────────────────────────────────────────────
//
// One flat block of globals rather than a struct, because every screen and
// the main loop read them and an .ino has no header to share a type through.
//
// These belong to the core and to nothing else. A feature that needs to know
// about them is TOLD, through PFFeatureFrame (features/pf_feature.h) - which is why
// that struct carries chromeVisible() and the mode instead of letting an
// feature reach in here. Adding a global for one feature's benefit is the most
// common way this file has drifted. The hook exists so you do not have to.
MatrixPanel_I2S_DMA *dma_display = nullptr;
int currentPatternIdx = 0;
enum AppMode {
MODE_RUNNING,
MODE_SELECTING
};
AppMode currentMode = MODE_RUNNING;
unsigned long lastMs = 0;
// Smoothed time to render one pattern frame, in microseconds. Read by /status;
// 0 until the first frame lands.
uint32_t renderFrameUs = 0;
float contentNoticeTimer = 0.0f;
// Global brightness: K1 longpress enters brightness mode, K1 rotation
// adjusts. Exits on a K1 click (instant), a second longpress, or 5s idle.
// The BRIGHTNESS notice stays on screen for the whole time the mode is
// active — the moment it disappears you're out. (It used to flash for
// 1.2s while the mode silently lived on for 5s, so you could never tell
// which state you were in.) Value persists in NVS.
Preferences prefs;
// NVS writes are the one kind of failure here whose symptom arrives a boot
// later, so they are counted rather than assumed. Published by /api/status:
// a board that is quietly failing to persist anything says so in a number
// instead of behaving strangely once, on somebody else's desk.
bool nvsUsable = false;
uint32_t nvsFailures = 0;
uint8_t currentBrightness = DEFAULT_BRIGHTNESS;
// What the panel is actually running at. Equal to currentBrightness unless the
// power clamp is holding it down (core_power.h) — kept separately so the
// clamp never rewrites the person's own setting.
uint8_t appliedBrightness = DEFAULT_BRIGHTNESS;
bool brightnessAdjusting = false;
uint32_t brightnessIdleAtMs = 0;
bool brightnessDirty = false;
// NETWORK screen: K2 longpress enters a status view. It lists whichever
// features declare a short name and a runtime switch — the sketch does not know
// what they are. Inside, TURNING K2 toggles the first row and K3 the second (right
// = on, left = off; both persist in NVS). Rotation, not clicks, so the K2
// longpress used to exit can't flip anything. Second K2 longpress or idle
// exits.
// NOTE ON THE NAME: this is the NETWORK screen's visibility flag, nothing
// more. OSC was hard-wired as that screen's first row back when it lived in
// the core; it is an ordinary feature now and the rows are built from whatever
// is loaded. The name survives because renaming it touches a dozen call sites
// for no behaviour change. It does NOT mean this file knows about OSC.
bool oscInfoShowing = false;
uint32_t oscInfoIdleAtMs = 0;
// NETWORK screen redraw pacing. The screen is static text: redrawing and
// flipping it every loop iteration (hundreds of Hz — no pattern renders in
// this mode) races the panel scanout and the text visibly flickers. Redraw
// only on entry, on a toggle, or at a slow poll so status changes still show.
uint32_t netInfoDrawnAtMs = 0;
bool netInfoDirty = false;
// KNOB MAP screen: K3 longpress shows which physical knob is which number
// (front view: K1 top-right, K2 top-left, K3 bottom-right, K4 bottom-left).
// Turning any knob lights its digit up green, so each knob can be verified
// without leaving the screen. K3 click, a second K3 longpress, or idle exits.
bool knobMapShowing = false;
uint32_t knobMapIdleAtMs = 0;
uint32_t knobMapDrawnAtMs = 0;
bool knobMapDirty = false;
uint32_t knobMapActiveAtMs[4] = {0, 0, 0, 0};
// SLEEP: panel off, board idle, still on the network (src/core_sleep.h).
// Entered by turning K1 on the NETWORK screen, by <prefix>/sleep over MQTT, or
// by GET /api/display?sleep=1. Woken by any PHYSICAL knob turn or button press
// — sleepKnobSnapshot is the reference the turn is measured against, because
// input.knobDeltas has remote deltas merged into it by then and a show still
// streaming knob values must not switch the lights back on.
long sleepKnobSnapshot[4] = {0, 0, 0, 0};
// The selected pattern, remembered across sleep and across power-off. Stored as
// a SLUG rather than an index: installing or deleting one .pfm renumbers the
// list, so an index would come back as somebody else's pattern. Written on a
// debounce for the same reason brightness is — spinning K4 through fifty
// patterns should not be fifty NVS writes.
bool patternDirty = false;
uint32_t patternChangedAtMs = 0;
const uint32_t PATTERN_SAVE_DELAY_MS = 3000;
// Restoring a module at boot is not the same risk as picking one by hand.
// Loading any .pfm drops internal heap from ~14 KB to ~5 KB (measured), which
// is under what the console needs to send a page and low enough that a printf
// can fail to allocate its lock and abort() — so a pattern that is merely
// marginal can take the board down a few seconds after it starts. Picked by
// hand that is a crash you reboot out of; remembered, the reboot restores the
// same pattern and does it again, every 4 seconds, with the console alive too
// briefly to fix anything. Only a full NVS erase gets out of that, which no
// owner can do.
//
// So the restore arms a latch first and disarms it once the pattern has run
// long enough to be trusted. A boot that finds the latch still armed knows the
// previous boot did not survive its own remembered pattern, and forgets it.
// Origin is compiled in and cannot fail this way, so remembering it costs no
// NVS traffic at all.
bool patternLatchArmed = false;
// ── SELECT-mode browsing ────────────────────────────────────────
// The knob moves the highlight; the pattern loads only once the knob has
// rested for SELECT_SETTLE_MS. Until it does, the panel shows the
// highlighted pattern's thumbnail (src/core_thumbs.h says why: a module's
// setup() can take seconds, and loading on every detent froze the loop for
// that long while the encoder kept counting). A thumbnail is taken when a
// pattern is LEFT, provided it has run for THUMB_MIN_RUN_MS and the canvas
// still holds its frame rather than another pattern's thumbnail.
const uint32_t SELECT_SETTLE_MS = 350;
// Detents per pattern while browsing. One per detent overshot: a hand that
// meant one pattern landed two or three along, and once the load stopped
// holding the frame that overshoot was the whole of what still felt wrong.
// Whole steps move the highlight; the remainder waits for the next detent
// and a change of direction cancels it, so nothing creeps.
const int SELECT_DETENTS_PER_STEP = 3;
int selectAccum = 0;
const uint32_t THUMB_MIN_RUN_MS = 2000; // after setup() returns; a heavy module's first two seconds are already its picture
bool selectPending = false; // the highlight moved and nothing has loaded since
uint32_t selectMovedAtMs = 0;
bool canvasShowsThumb = false; // the canvas holds a thumbnail, not the running pattern
const uint32_t PATTERN_LATCH_CLEAR_MS = 15000;
// Shown whenever no pattern is resident: the web console paused it, or a module
// refused to load. Before this the render loop simply drew nothing and flipped
// the buffer anyway, so the panel showed a torn, flickering leftover frame —
// which read as "the pattern is broken" even when the cause was just an open
// browser tab.
uint32_t pausedDrawnAtMs = 0;
bool pausedDirty = true;
// UPDATE screen: entered from the NETWORK screen by turning K4. While it
// shows, the /update endpoint is ARMED — that is the whole security model
// (see core_web_update.h): flashing over the LAN requires someone at the
// device first. K4 click exits (disarming), except mid-flash. The idle
// timeout is long because the user is at their computer fetching a .bin.
bool updateShowing = false;
uint32_t updateIdleAtMs = 0;
uint32_t updateDrawnAtMs = 0;
bool updateDirty = false;
// The percentage the upload handler last reported, from the network core;
// loop() draws it. -1 when nothing is being received.
volatile int uploadPctShown = -1;
const uint32_t MODE_HOLD_MS = 1000;
const uint32_t BRIGHTNESS_IDLE_MS = 5000;
// How long the value has to be still before it is written to NVS - the
// console's slider sends a step for every pixel of a drag.
const uint32_t BRIGHTNESS_SAVE_DELAY_MS = 3000;
const uint32_t OSC_INFO_IDLE_MS = 8000;
const uint32_t NET_INFO_REDRAW_MS = 250;
const uint32_t KNOB_MAP_IDLE_MS = 8000;
const uint32_t KNOB_MAP_HILITE_MS = 600;
const uint32_t UPDATE_IDLE_MS = 600000; // 10 min — downloading a build takes a while
const float CONTENT_NOTICE_SECONDS = 1.0f;
// Logical knob N = front-panel KN = physical encoder N. The PCB routes ENCn
// straight to the Kn position (verified on the board) — an earlier build
// mirrored K1<->K2 here, which put every K1/K2 feature on the wrong physical
// knob. Keep this an identity unless a future enclosure actually flips the
// panel.
const int LOGICAL_TO_PHYSICAL_KNOB[4] = {0, 1, 2, 3};
Button* logicalButton(int logicalIdx) {
switch (LOGICAL_TO_PHYSICAL_KNOB[logicalIdx]) {
case 0: return &btn1;
case 1: return &btn2;
case 2: return &btn3;
default: return &btn4;
}
}
// Defined below with the other screens; declared here because setup()
// installs it as the upload progress callback.
void drawUpdateScreen(int uploadPct);
// ── Remembering the selected pattern ─────────────────────────────────
// Mark on every path that moves currentPatternIdx (knob, OSC, MQTT, HTTP); the
// write itself happens later, from loop(), once the choice has settled.
void notePatternChanged() {
patternDirty = true;
patternChangedAtMs = millis();
}
void savePatternIfSettled() {
if (!patternDirty) return;
// Not while browsing: in SELECT mode the highlighted pattern is a question,
// not an answer.
if (currentMode == MODE_SELECTING) return;
if ((millis() - patternChangedAtMs) < PATTERN_SAVE_DELAY_MS) return;
char slug[MODULE_NAME_BYTES];
patternSlugAt(currentPatternIdx, slug, sizeof(slug));
if (!slug[0]) { patternDirty = false; return; }
// The flag clears only once the write is known to have happened. It used to
// clear first, so a putString that returned 0 - heap starvation is a
// demonstrated live state on this board - silently threw the save away and
// was never retried. Bumping the timestamp reuses the existing 3 s debounce
// as the backoff, so a permanently broken NVS costs one attempt every 3 s
// rather than one per loop.
if (prefs.putString("pattern", slug) == 0) {
++nvsFailures;
patternChangedAtMs = millis();
Serial.printf("[NVS] pattern save FAILED (%s) - will retry\n", slug);
return;
}
patternDirty = false;
Serial.printf("[NVS] pattern saved: %s\n", slug);
}
// Resolve the remembered slug back to a position in the list built this boot.
// Returns 0 (Origin) whenever that cannot be done — a module the owner deleted
// since, a pack that was replaced, a first boot. Origin is compiled in
// precisely so there is always something to fall back to.
int restoreSavedPatternIdx() {
char slug[MODULE_NAME_BYTES] = {};
prefs.getString("pattern", slug, sizeof(slug));
if (!slug[0]) return 0;
// findPatternByName matches display name AND slug, so a pattern that was
// saved before it had a sidecar name still resolves.
int idx = findPatternByName(slug);
if (idx < 0) {
Serial.printf("[NVS] saved pattern \"%s\" is gone - starting at %s\n",
slug, patterns[0].name);
return 0;
}
// Presets cannot exhaust the heap the way a module can, so pattern 0 skips
// the latch entirely and a default board never writes NVS on boot.
if (idx != 0) {
if (prefs.getBool("pat_trying", false)) {
// Armed from the previous boot and never disarmed: that boot died with
// this pattern resident. Forget it rather than repeat the crash — the
// owner can pick it again, and if it was a one-off nothing is lost but
// the memory of it.
prefs.remove("pattern");
// Benign if it fails - the slug is already gone, so the next boot leaves
// before it reads the latch - but counted, because a board that cannot
// write here cannot write anywhere and should say so once.
if (prefs.putBool("pat_trying", false) == 0) ++nvsFailures;
Serial.printf("[NVS] \"%s\" did not survive the last boot - forgetting it, "
"starting at %s\n",
slug, patterns[0].name);
return 0;
}
// A failed arm is the mild direction of the same failure: the latch exists
// only in RAM, so a boot that then dies leaves nothing for the next one to
// read, and the pattern is retried instead of forgotten. Worth counting,
// not worth refusing to start over.
if (prefs.putBool("pat_trying", true) == 0) {
++nvsFailures;
Serial.println("[NVS] latch arm FAILED - a crash this boot will not be remembered");
}
patternLatchArmed = true;
}
Serial.printf("[NVS] restoring pattern: %s\n", patterns[idx].name);
return idx;
}
// Disarm once the restored pattern has run long enough that the crash this
// guards against would already have happened. Runs from loop(); costs one NVS
// write per boot, and only on a board that remembers a module.
void clearPatternLatchIfStable() {
if (!patternLatchArmed) return;
if (millis() < PATTERN_LATCH_CLEAR_MS) return;
// Disarm in RAM only after NVS agrees, and this is the one of the two that
// does damage. Clearing the flag first meant a failed write left "pat_trying"
// set on disk with nothing left to retry it, and the NEXT boot reads that as
// "the last boot died with this pattern resident" and calls prefs.remove()
// on a pattern that was never at fault. A silently lost write here does not
// fail to save something; it actively deletes something good.
if (prefs.putBool("pat_trying", false) == 0) {
++nvsFailures;
Serial.println("[NVS] latch disarm FAILED - retrying, or the next boot "
"forgets a pattern that was fine");
return;
}
patternLatchArmed = false;
}
// ── Thumbnails ───────────────────────────────────────────────
// Keep the running pattern's last frame as its picture, if it is worth
// keeping: it has run for a while (the first frames of a module are its
// setup), the canvas holds its frame and not a thumbnail painted over it,
// and nothing is being drawn over the pattern (the calibration card). Called
// on every path that is about to replace the running pattern, and when
// SELECT opens - before the first thumbnail lands on the canvas.
void snapshotActiveIfRipe() {
if (activePatternIdx < 0 || !patterns) return;
if (canvasShowsThumb) return;
if (CalibPattern::overrideOn) return;
if (patterns[activePatternIdx].hidden) return;
if ((uint32_t)(millis() - activatedAtMs) < THUMB_MIN_RUN_MS) return;
char slug[MODULE_NAME_BYTES];
patternSlugAt(activePatternIdx, slug, sizeof(slug));
if (!slug[0]) return;
PFThumbs::capture(slug, moduleStorageMounted);
}
// The outgoing pattern's picture taken first, then the switch - on the
// loader task, so the frame never waits for a setup() (pattern_registry.h,
// "Loading without stopping the frame"). Every switch that is not the boot
// restore goes through here.
bool activateWithSnapshot(int index) {
snapshotActiveIfRipe();
return activatePatternAsync(index);
}
// A pattern that is not running: its picture, if it has one. With
// blankIfNone the panel goes dark under the overlay's name (SELECT, where a
// stale picture would be a lie); without it the caller holds the frame that
// is already on the panel. Returns whether a picture was painted.
bool drawPatternThumbnail(int index, bool blankIfNone) {
char slug[MODULE_NAME_BYTES];
patternSlugAt(index, slug, sizeof(slug));
const uint16_t* px = slug[0] ? PFThumbs::get(slug) : nullptr;
if (!px && !blankIfNone) return false;
if (px) PFThumbs::paint(px);
else PFCanvas::clear();
PFCanvas::present();
canvasShowsThumb = true;
return px != nullptr;
}
// ── Boot ──────────────────────────────────────────────────────
//
// Order matters and the reasons sit on the individual lines. The one rule
// worth stating up front: PFFeatures::setup() runs LAST, after the panel, the
// pattern registry and the HTTP server exist, so a feature may assume all
// three. Wi-Fi is the exception - it starts non-blocking and is NOT up yet
// when setup() returns, which is exactly what the onNetwork hook is for.
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("\n=== Patternflow OS Booting... ===");
// Why we are booting. A board that rebooted itself and one that was
// unplugged look the same from the network — uptime back near zero — and
// two reboots nobody noticed in one afternoon are what put this line here.
// "poweron" is a hand on the plug; "task_wdt", "panic" or "brownout" is a
// bug, and on a serial logger the lines above this one say which.
// /api/status carries the same word as resetReason.
Serial.printf("[BOOT] reset reason: %s\n",
PatternflowStatusHttp::resetReasonName());
reportHeap("boot");
initEncoders();
initDisplay();
reportHeap("after display");
nvsUsable = prefs.begin("patternflow", false);
if (!nvsUsable) {
Serial.println("[NVS] namespace would not open - nothing will persist this boot");
}
currentBrightness = prefs.getUChar("brightness", DEFAULT_BRIGHTNESS);
dma_display->setBrightness8(currentBrightness);
// The clamp needs a frame's demand before it can say anything, so it starts
// out of the way and takes over from the first present().
PatternflowPower::load();
// MQTT channel + role from /mqtt. loadConfig first (broker + prefix +
// Wall clock + weather (NTP / FlowLocal HTTP, saved UTC offset) and the
// Features load their own settings here; sync itself starts with Wi-Fi.
// The core exposes an extension point for /api/status; the features are
// what fills it. Wiring the two is the sketch's job, because it is the
// only file that is allowed to know about both.
PatternflowStatusHttp::extraStatus = PFFeatures::appendStatus;
PatternflowStatusHttp::extraCaps = PFFeatures::emitCaps;
PatternflowStatusHttp::extraNav = PFFeatures::emitNav;
// Same arrangement for the frame on its way to the panel: the canvas
// exposes one hook, the dispatcher fans it out, and only this file knows
// both ends.
PFCanvas::composeHook = PFFeatures::composeFrame;
PFFeatures::setup();
// Start Wi-Fi non-blocking: boot does NOT wait for the join. OSC, OTA,
// and the audio-react server are started from the connect edge in loop()
// (and re-announced on reconnect), so patterns render immediately whether
// or not Wi-Fi is up yet.
PatternflowWifi::begin();
PatternflowHotspot::begin();
PatternflowImprov::begin();
// The network task (Core 0): Wi-Fi, the console, Improv, OTA. Started now
// so joining Wi-Fi never waits on the first frame; the HTTP server itself
// is not serviced until loop() has registered every route.
PatternflowNetTask::begin();
beginPatternLoader(); // one reusable stack, before any module fragments the heap
// Wireless-update progress. The upload handler runs on the network core
// (since 3.9.1), so this callback must not draw: for a while it did, and
// the UPDATE card and the pattern's frames took turns on the panel — two
// tasks flipping one DMA buffer, a picture that tore for the length of
// the upload. It records the percentage and raises the UPDATE screen;
// loop() draws it, the way it draws every other card.
PatternflowWebUpdate::progressCallback = [](int pct) {
uploadPctShown = pct;
updateShowing = true;
updateDirty = true;
updateIdleAtMs = millis();
};
buildPatternList(); // presets first (pattern 1 = Origin), FATFS modules appended
// Presets only. A module's setup() runs inside the loader when it is
// activated, because its code is not resident until then.
//
// Note: the fork this came from stopped HUB75 DMA around module loads to keep
// its ISR from tripping the interrupt watchdog mid-relocation. This library
// version has stopDMAoutput() but no way back, so that trick is unavailable —
// the loader's yield() every 64 relocations is the mitigation. Revisit if a
// watchdog reset actually shows up on hardware.
for (int i = 0; i < NUM_PATTERNS; i++) {
if (!patterns[i].modulePath) patterns[i].setup();
}
// The calibration test card lives outside the pattern list (it is an overlay
// summoned by /api/display, not art) but still bakes its tables once here.
CalibPattern::setup();
// Back to whatever was running before the power went away. Only meaningful
// once the list exists, hence after buildPatternList().
currentPatternIdx = restoreSavedPatternIdx();
if (!activatePattern(currentPatternIdx) && currentPatternIdx != 0) {
// A remembered module that no longer loads (corrupt upload, half-finished
// write). The PATTERN FAILED screen is the right answer for a pattern
// somebody just picked, and the wrong one for a boot — nobody asked for
// this pattern in this session, so fall back rather than greet them with
// an error.
Serial.printf("[NVS] saved pattern failed to load (%s) - falling back\n",
PFModuleLoader::error());
currentPatternIdx = 0;
activatePattern(currentPatternIdx);
}
Serial.printf("Current Pattern: %s\n", patterns[currentPatternIdx].name);
lastMs = millis();
}
// ── Text and drawing helpers ───────────────────────────────────
//
// Shared by every screen below. Panel text is 6x8 ASCII with no glyphs past
// 0x7F, which is why asciiFold exists - pattern names are UTF-8 and come from
// anywhere in the world. Everything drawn on the panel goes through here.
const char* currentContentName() {
return patterns[currentPatternIdx].name;
}
// Pattern names are UTF-8 — a community pattern is as likely to be called
// "Dynamic Moiré" as "Origin" — but the panel's font is a 5x7 ASCII table.
// Handed the raw bytes it draws one wrong glyph per byte, so "Moiré" came out
// as "Moiré". Fold the Latin-1 letters onto their base character and reduce
// anything else to one '?', so a name is always readable and never explodes
// into noise. The JSON APIs keep the real UTF-8 name; this is display only.
void asciiFold(const char* source, char* out, size_t capacity) {
static const char* const LATIN1 =
"AAAAAAACEEEEIIII" // C0-CF
"DNOOOOOxOUUUUYPs" // D0-DF
"aaaaaaaceeeeiiii" // E0-EF
"dnooooo/ouuuuypy"; // F0-FF
size_t w = 0;
for (const uint8_t* p = (const uint8_t*)source; *p && w + 1 < capacity; ) {
uint8_t c = *p;
if (c >= 0x20 && c <= 0x7e) {
out[w++] = (char)c;
p++;
} else if ((c == 0xc3) && p[1]) {
// Two-byte Latin-1 supplement: 0xC3 0x80..0xBF maps to U+00C0..U+00FF.
out[w++] = LATIN1[p[1] & 0x3f];
p += 2;
} else {
out[w++] = '?';
// Skip the whole multi-byte sequence so one character yields one '?'.
p++;
while ((*p & 0xc0) == 0x80) p++;
}
}
out[w] = '\0';
}
void drawCenteredText(const char* text, int y, uint16_t color, int textSize = 1) {
int16_t x1, y1;
uint16_t w, h;
dma_display->setTextSize(textSize);
dma_display->setTextColor(color);
dma_display->getTextBounds(text, 0, 0, &x1, &y1, &w, &h);
dma_display->setCursor((dma_display->width() - w) / 2, y);
dma_display->print(text);
}
// Centered text on a small dark scrim band, so it stays legible drawn on top
// of the live pattern the SELECT screen now previews behind the overlay.
// One fillRect + one text draw per label — cheap enough not to slow the frame
// (a per-glyph outline tripled the per-frame pixel writes and tore the
// double-buffered panel).
void drawCenteredTextScrim(const char* text, int y, uint16_t color, int textSize = 1) {
int16_t x1, y1;
uint16_t w, h;
dma_display->setTextSize(textSize);
dma_display->getTextBounds(text, 0, 0, &x1, &y1, &w, &h);
int x = (dma_display->width() - w) / 2;
dma_display->fillRect(x - 2, y - 2, w + 4, h + 4, 0);
dma_display->setTextColor(color);
dma_display->setCursor(x, y);
dma_display->print(text);
}
// ── Panel palette + shared chrome for the info screens ──────────────
// The on-device screens borrow the patternflow.work design system's motifs
// at 64×128 scale: one LED-orange accent (#E8552E), hairline rules, and a
// glowing-dot-plus-title header (the web's version tag). Green/red stay
// reserved for live state (on/off, ok/error); orange is brand + progress.
uint16_t pfLedC() { return dma_display->color565(232, 85, 46); }
uint16_t pfWhiteC() { return dma_display->color565(255, 255, 255); }
uint16_t pfGrayC() { return dma_display->color565(140, 140, 140); }
uint16_t pfDimC() { return dma_display->color565(90, 90, 90); }
uint16_t pfRuleC() { return dma_display->color565(60, 60, 60); }
uint16_t pfGreenC() { return dma_display->color565(80, 220, 130); }
uint16_t pfRedC() { return dma_display->color565(255, 80, 80); }
uint16_t pfBlueC() { return dma_display->color565(120, 180, 255); }
// LED dot + centered title + hairline rule at the top of a portrait info
// screen (NETWORK / UPDATE). Content starts below y=15.
void drawScreenHeader(const char* title) {
int16_t x1, y1;
uint16_t tw, th;
dma_display->setTextSize(1);
dma_display->getTextBounds(title, 0, 0, &x1, &y1, &tw, &th);
int x = (dma_display->width() - (int)(tw + 6)) / 2;
dma_display->fillRect(x, 7, 2, 2, pfLedC());
dma_display->setTextColor(pfWhiteC());
dma_display->setCursor(x + 6, 4);
dma_display->print(title);
dma_display->drawFastHLine(4, 15, dma_display->width() - 8, pfRuleC());
}
// ── The info screens ───────────────────────────────────────────
//
// The overlays a person reaches with a longpress: NETWORK, the knob map, the
// update progress screen, the paused screen, and the brief notices. They draw
// over the running pattern, and every one of them is core.
//
// A feature does NOT get a screen here. It gets a row on NETWORK by declaring
// shortName + isRuntimeEnabled + setRuntimeEnabled in its descriptor, and a
// page in the web console for anything larger. That keeps the panel readable
// on a 64px line, and keeps this file from growing one screen per feature -
// which is what it would do, because a screen is always the easiest thing to
// add and the hardest to take back once someone has learned it.
// Notices sit on the same tight scrim the SELECT overlay uses (text bounds
// + 2px) instead of a full-width black band — less of the live pattern is
// blotted out and every overlay text now shares one look.
// The name of what just arrived, for a few seconds over the pattern.
// Upright — portrait, like every other card — where it used to run along the
// long axis and read sideways on a panel stood on its end; and wrapped the
// way the SELECT screen wraps it, since a community pattern's name is as
// likely to be "Two-stream phase-space vortices" as "Origin".
void drawContentNotice() {
char name[MODULE_NAME_BYTES];
asciiFold(currentContentName(), name, sizeof(name));
dma_display->setRotation(1);
PatternflowUiText::drawWrappedName(name, dma_display->height() / 2,
dma_display->color565(255, 255, 255));
PatternflowUiText::useDefaultFont();
dma_display->setRotation(0);
}
// Label plus a thin LED-orange level bar on one shared scrim — the bar
// makes the level readable at a glance mid-turn, without watching digits.
void drawBrightnessNotice() {
char buf[24];
int pct = (int)((currentBrightness * 100 + 127) / 255);
snprintf(buf, sizeof(buf), "BRIGHTNESS %d%%", pct);
int16_t x1, y1;
uint16_t w, h;
dma_display->setTextSize(1);
dma_display->getTextBounds(buf, 0, 0, &x1, &y1, &w, &h);
int x = (dma_display->width() - (int)w) / 2;
int y = dma_display->height() - 16;
dma_display->fillRect(x - 2, y - 2, w + 4, h + 9, 0);
dma_display->setTextColor(pfWhiteC());
dma_display->setCursor(x, y);
dma_display->print(buf);
int by = y + (int)h + 3;
dma_display->drawFastHLine(x, by, w, pfDimC());
int fw = ((int)w * pct) / 100;
if (fw > 0) dma_display->drawFastHLine(x, by, fw, pfLedC());
// While the power clamp is holding brightness down, turning the knob up
// moves this bar and changes nothing on the panel. Say so, or it reads as
// a broken control: the dim segment is the part the clamp is withholding.
if (PatternflowPower::limiting) {
int allowedPct = (int)((PatternflowPower::allowedBrightness * 100 + 127) / 255);
int aw = ((int)w * allowedPct) / 100;
if (aw < fw) dma_display->drawFastHLine(x + aw, by, fw - aw, pfDimC());
char lim[20];
snprintf(lim, sizeof(lim), "PWR %umA", (unsigned)PatternflowPower::estimateMa);
uint16_t lw, lh;
dma_display->getTextBounds(lim, 0, 0, &x1, &y1, &lw, &lh);
int lx = (dma_display->width() - (int)lw) / 2;
int ly = y - (int)lh - 3;
dma_display->fillRect(lx - 2, ly - 2, lw + 4, lh + 4, 0);
dma_display->setTextColor(pfLedC());
dma_display->setCursor(lx, ly);
dma_display->print(lim);
}
}
// NETWORK info + toggle screen (K2 longpress). Shows Wi-Fi plus a row per
// toggleable feature, whatever those turn out to be.
// TURN K2 to toggle the first row, K3 the second (rotation,
// not a click — a K2 click exits). Drawn PORTRAIT (rotation 1, 64×128):
// vertical is the device's primary mounting, so this screen reads upright
// like the SELECT overlay. The 64px line width fits ~10 chars at text
// size 1, hence the stacked layout and the IP split across two lines.
void drawNetworkInfo() {
dma_display->setRotation(1);
dma_display->fillScreen(0);
int w = dma_display->width(); // 64 in portrait
drawScreenHeader("NETWORK");
// Feature rows: status dot + name on the left, state right-aligned in
// the state's color — reads like the web console's tag pills.
// Every row comes from a feature that says it can be switched off here,
// so this screen lists features it knows nothing about. OSC used to be
// hard-coded as the first row back when it lived in the core; it is an
// feature now and arrives the same way as the rest.
//
// Two rows is the budget: they start at y=22 on an 11 px pitch and the
// Wi-Fi line is fixed at y=50, so a third would draw over it. A build
// with more toggleable features than that shows the first ones and the
// rest stay web-only — the alternative is a screen that silently
// overlaps itself.
constexpr int MAX_FEATURE_ROWS = 2;
struct FeatureRow { const char* name; bool compiled; bool on; };
FeatureRow rows[MAX_FEATURE_ROWS];
int rowCount = 0;
for (size_t t = 0; t < PFFeatures::toggleableCount() && rowCount < MAX_FEATURE_ROWS; t++) {
size_t a = PFFeatures::toggleableAt(t);
rows[rowCount++] = { PFFeatures::shortName(a), true, PFFeatures::runtimeEnabled(a) };
}
dma_display->setTextSize(1);
for (int i = 0; i < rowCount; i++) {
int y = 22 + i * 11;
bool on = rows[i].compiled && rows[i].on;
uint16_t st = rows[i].compiled ? (on ? pfGreenC() : pfRedC()) : pfDimC();
dma_display->fillRect(8, y + 2, 2, 2, st);
dma_display->setTextColor(pfWhiteC());
dma_display->setCursor(14, y);
dma_display->print(rows[i].name);
const char* val = rows[i].compiled ? (on ? "ON" : "OFF") : "N/A";
int16_t x1, y1;
uint16_t tw, th;
dma_display->getTextBounds(val, 0, 0, &x1, &y1, &tw, &th);
dma_display->setTextColor(st);
dma_display->setCursor(w - 8 - (int)tw, y);
dma_display->print(val);
}
// Wi-Fi status + IP. A full IPv4 (up to 15 chars) doesn't fit one
// portrait line — split after the second octet's dot.
bool wifiUp = PatternflowWifi::isConnected();
if (!wifiUp && PatternflowHotspot::up) {
// No station link, but the panel is a network of its own: the name to
// join, split at the dash so it fits the portrait width. The password
// is the documented default unless the owner changed it on /wifi.
drawCenteredText("HOTSPOT", 50, pfGreenC(), 1);
String name = PatternflowHotspot::name();
int dash = name.indexOf('-');
if (dash < 0) dash = name.length();
drawCenteredText(name.substring(0, dash).c_str(), 62, pfWhiteC(), 1);
drawCenteredText(name.substring(dash).c_str(), 72, pfWhiteC(), 1);
} else {
drawCenteredText(PatternflowWifi::statusText(), 50, wifiUp ? pfGreenC() : pfBlueC(), 1);
String ip = PatternflowWifi::ipString();
if (ip.length() <= 10) {
drawCenteredText(ip.c_str(), 62, pfGrayC(), 1);
} else {
int cut = ip.indexOf('.', ip.indexOf('.') + 1) + 1;
drawCenteredText(ip.substring(0, cut).c_str(), 62, pfGrayC(), 1);
drawCenteredText(ip.substring(cut).c_str(), 72, pfGrayC(), 1);
}
}
// Hints under a hairline rule. The rows above come from whatever features
// are loaded, so the lines naming them have to as well: "TURN K2/K3" over
// "OSC / AUD" was hard-coded, and only the audio edition has those two.
// Every other build drew an instruction for knobs that toggle nothing.
//
// Lines are collected and then dropped onto a fixed ladder, so a build
// with all five keeps the layout this screen was tuned for and a build
// with fewer packs up from the top, the same way the feature rows do.
dma_display->drawFastHLine(4, 82, w - 8, pfRuleC());
const char* hints[5];
uint16_t hintColors[5];
int hintCount = 0;
char toggleNames[16];
if (rowCount > 0) {
hints[hintCount] = rowCount > 1 ? "TURN K2/K3" : "TURN K2";
hintColors[hintCount++] = pfDimC();
if (rowCount > 1) {
snprintf(toggleNames, sizeof(toggleNames), "%s / %s", rows[0].name,
rows[1].name);
} else {
snprintf(toggleNames, sizeof(toggleNames), "%s", rows[0].name);
}
hints[hintCount] = toggleNames;
hintColors[hintCount++] = pfDimC();
}
if (PatternflowSleep::isCompiledIn()) {
hints[hintCount] = "K1 = SLEEP";
hintColors[hintCount++] = pfLedC();
}
if (PatternflowWebUpdate::isCompiledIn()) {
hints[hintCount] = "K4=UPDATE";
hintColors[hintCount++] = pfDimC();
}
hints[hintCount] = "K2 = EXIT";
hintColors[hintCount++] = pfDimC();
static const int HINT_Y[5] = {86, 95, 105, 114, 123};
for (int i = 0; i < hintCount; i++) {
drawCenteredText(hints[i], HINT_Y[i], hintColors[i], 1);
}
dma_display->setRotation(0);
}
// Shown when no pattern is resident. Two causes, and the difference matters to
// whoever is standing in front of the panel:
//
// CONSOLE MODE a browser has a console page open, so the pattern module was
// evicted to give the web server the RAM it needs (see
// core_patterns_http.h). Resumes on its own.
// PATTERN FAILED a module refused to load. The loader's reason is printed,
// because "it just doesn't work" is the least useful bug report
// and this turns it into a specific one.
//
// Drawn PORTRAIT like the other info screens.
void drawPausedScreen() {
dma_display->setRotation(1);
dma_display->fillScreen(0);
const int w = dma_display->width();
const int h = dma_display->height();
const bool consolePaused = PatternflowPatternsHttp::isConsolePaused();
const char* reason = PFModuleLoader::error();
drawScreenHeader(consolePaused ? "CONSOLE" : "PATTERN");
if (consolePaused) {
drawCenteredText("PAUSED", 26, pfWhiteC(), 1);
drawCenteredText("web console", 42, pfDimC(), 1);
drawCenteredText("is open", 52, pfDimC(), 1);
dma_display->drawFastHLine(4, h - 28, w - 8, pfRuleC());
drawCenteredText("RESUMES", h - 23, pfDimC(), 1);
drawCenteredText("WHEN DONE", h - 13, pfDimC(), 1);
} else {
drawCenteredText("FAILED", 26, pfRedC(), 1);
// The reason can be long (a missing symbol name); wrap it across the
// 64px-wide portrait screen rather than clipping it to nothing.
if (reason && reason[0]) {
char line[12];
int y = 44;
for (size_t i = 0; reason[i] && y < h - 26; i += 10, y += 10) {
size_t n = 0;
while (n < 10 && reason[i + n]) { line[n] = reason[i + n]; n++; }
line[n] = '\0';
drawCenteredText(line, y, pfGrayC(), 1);
}
}
dma_display->drawFastHLine(4, h - 18, w - 8, pfRuleC());
drawCenteredText("TURN K4", h - 13, pfDimC(), 1);
}
dma_display->setRotation(0);
}
// UPDATE screen (NETWORK screen → turn K4). Drawn PORTRAIT like the other
// info screens. Idle: shows where to drop the .bin — the .local name AND
// the raw IP, because mDNS is unreliable on Android (issue #232). During
// a flash this is redrawn by the progress callback (uploadPct >= 0), since
// the main loop is blocked inside handleClient() for the whole upload;
// pass -1 when drawing from the loop.
void drawUpdateScreen(int uploadPct) {
dma_display->setRotation(1);
dma_display->fillScreen(0);
int w = dma_display->width(); // 64 in portrait
int h = dma_display->height(); // 128 in portrait
drawScreenHeader("UPDATE");
if (uploadPct >= 0 || PatternflowWebUpdate::isUploading()) {
int pct = (uploadPct >= 0) ? uploadPct
: (int)PatternflowWebUpdate::progressPercent();
drawCenteredText("FLASHING", 26, pfGrayC(), 1);
char buf[8];
snprintf(buf, sizeof(buf), "%d%%", pct);
drawCenteredText(buf, 44, pfWhiteC(), 2);
// LED-orange fill on a hairline frame — same accent as the web page's
// progress bar.
int bx = 8, by = 70, bw = w - 16;
dma_display->drawRect(bx, by, bw, 7, pfRuleC());
int fill = ((bw - 4) * constrain(pct, 0, 100)) / 100;
if (fill > 0) dma_display->fillRect(bx + 2, by + 2, fill, 3, pfLedC());
dma_display->drawFastHLine(4, h - 26, w - 8, pfRuleC());
drawCenteredText("KEEP POWER", h - 21, pfDimC(), 1);
drawCenteredText("ON", h - 11, pfDimC(), 1);
} else if (PatternflowWebUpdate::isRebootPending()) {
drawCenteredText("DONE", 46, pfGreenC(), 1);
drawCenteredText("REBOOTING", 60, pfWhiteC(), 1);
} else {
bool wifiUp = PatternflowWifi::isConnected();
if (PatternflowWebUpdate::hasError()) {
drawCenteredText("FAILED", 21, pfRedC(), 1); // details went to the browser
} else {
drawCenteredText(wifiUp ? "READY" : "NO WIFI", 21, wifiUp ? pfGreenC() : pfRedC(), 1);
}
if (wifiUp) {
drawCenteredText("DROP .BIN:", 36, pfDimC(), 1);
drawCenteredText(PF_OTA_HOSTNAME, 48, pfWhiteC(), 1);
drawCenteredText(".local", 58, pfWhiteC(), 1);
drawCenteredText("/update", 68, pfWhiteC(), 1);
// Raw IP as the mDNS fallback, split like the NETWORK screen.
String ip = PatternflowWifi::ipString();
if (ip.length() <= 10) {
drawCenteredText(ip.c_str(), 82, pfGrayC(), 1);
} else {
int cut = ip.indexOf('.', ip.indexOf('.') + 1) + 1;
drawCenteredText(ip.substring(0, cut).c_str(), 82, pfGrayC(), 1);
drawCenteredText(ip.substring(cut).c_str(), 92, pfGrayC(), 1);
}
} else {
drawCenteredText(PatternflowWifi::statusText(), 52, pfBlueC(), 1);
}
dma_display->drawFastHLine(4, h - 16, w - 8, pfRuleC());
drawCenteredText("K4 = EXIT", h - 11, pfDimC(), 1);
}
dma_display->setRotation(0);
}
// KNOB MAP screen (K3 longpress). Drawn PORTRAIT like the NETWORK screen.
// One numbered circle per knob at its front-view corner; a digit turns green
// for a moment while its knob is being turned. Same throttled-redraw scheme
// as the NETWORK screen (see netInfoDrawnAtMs).
void drawKnobMap() {
dma_display->setRotation(1);
dma_display->fillScreen(0);
int w = dma_display->width(); // 64 in portrait
int h = dma_display->height(); // 128 in portrait
// Center lockup: LED dot + title (the top header strip would collide
// with the corner circles, so the lockup sits mid-screen instead).
{
const char* title = "KNOB MAP";
int16_t x1, y1;
uint16_t tw, th;
dma_display->setTextSize(1);
dma_display->getTextBounds(title, 0, 0, &x1, &y1, &tw, &th);
int x = (w - (int)(tw + 6)) / 2;
int y = (h / 2) - 10;
dma_display->fillRect(x, y + 3, 2, 2, pfLedC());
dma_display->setTextColor(pfWhiteC());
dma_display->setCursor(x + 6, y);
dma_display->print(title);
}
drawCenteredText("TURN = SHOW", (h / 2) + 2, pfDimC(), 1);
drawCenteredText("K3 = EXIT", (h / 2) + 12, pfDimC(), 1);
// Front-view corners: K1 top-right, K2 top-left, K3 bottom-right,
// K4 bottom-left (indices are logical = physical after the identity map).
const int cx[4] = { w - 13, 13, w - 13, 13 };
const int cy[4] = { 14, 14, h - 14, h - 14 };
uint32_t now = millis();
for (int i = 0; i < 4; i++) {
bool active = knobMapActiveAtMs[i] != 0 &&
(now - knobMapActiveAtMs[i]) < KNOB_MAP_HILITE_MS;
// Active knob lights LED-orange (brand accent) with a soft fill; the
// digit stays white for contrast either way.
if (active) dma_display->fillCircle(cx[i], cy[i], 10, dma_display->color565(74, 27, 15));
dma_display->drawCircle(cx[i], cy[i], 10, active ? pfLedC() : pfWhiteC());
dma_display->setTextSize(1);
dma_display->setTextColor(pfWhiteC());
dma_display->setCursor(cx[i] - 2, cy[i] - 3);
dma_display->print((char)('1' + i));
}
dma_display->setRotation(0);
}
// Draws the SELECT overlay ON TOP of the live pattern preview the loop has
// already rendered into the buffer (so no fillScreen here). Each label sits on
// a small dark scrim so it stays readable over whatever pattern is behind it.
// ── SELECT mode and the banner ─────────────────────────────────
//
// Browsing the pattern list with K1, plus the banner overlay that features and
// the console can raise. Ranks are computed over VISIBLE patterns only: a
// hidden entry still occupies an index, and counting it produced a list that
// skipped a number and looked broken from the outside.
// Hidden patterns are skipped while browsing but were still counted, so a
// list with one hidden entry read "1 / 3" and then "3 / 3" — a missing 2
// that looks exactly like a bug, because from the outside it is one.
// Count and rank by what the screen will actually stop on.
static int visiblePatternCount() {
int n = 0;
for (int i = 0; i < NUM_PATTERNS; i++)
if (!patterns[i].hidden) n++;
return n;
}
static int visiblePatternRank(int idx) {
int n = 0;
for (int i = 0; i <= idx && i < NUM_PATTERNS; i++)
if (!patterns[i].hidden) n++;
return n; // 1-based; 0 only if idx itself is hidden and first
}
void drawSelectingMode() {
uint16_t screenH = dma_display->height();
const int visN = visiblePatternCount();
const int visI = visiblePatternRank(currentPatternIdx);
char pageStr[16];
snprintf(pageStr, sizeof(pageStr), "%d / %d", visI, visN);
drawCenteredTextScrim(pageStr, 10, dma_display->color565(190, 190, 190), 1);
// Position track under the page indicator: a hairline with an LED-orange
// marker at the highlighted pattern's place in the list — browsing with
// K4 reads as sliding along the line. Drawn on its own small scrim so it
// stays legible over the live preview.
{
int trackW = 40;
int tx = (dma_display->width() - trackW) / 2;
int ty = 23;
dma_display->fillRect(tx - 2, ty - 2, trackW + 4, 5, 0);
dma_display->drawFastHLine(tx, ty, trackW, pfDimC());
int mx = (visN > 1)
? tx + ((trackW - 3) * (visI > 0 ? visI - 1 : 0)) / (visN - 1)
: tx;
dma_display->fillRect(mx, ty - 1, 3, 3, pfLedC());
}
// The name is the whole point of this screen, and with patterns arriving
// from a community it is as likely to be "Chromatic Aberration Vortex" as
// "Origin". The stock font puts ~10 characters on this 64px-wide portrait
// line, so both used to run off the edges — the old size-2 branch was 12px
// per character, clipped from six characters on. Org_01 plus word wrap
// fits the long ones and still reads.
char name[MODULE_NAME_BYTES];
asciiFold(patterns[currentPatternIdx].name, name, sizeof(name));
PatternflowUiText::drawWrappedName(name, screenH / 2,
dma_display->color565(255, 255, 255));
PatternflowUiText::drawChromeLine("HOLD TO SELECT", screenH - 22,
dma_display->color565(200, 200, 200));
// Every later draw — the brightness notice, the next frame's pattern —
// assumes the built-in font's top-left placement.
PatternflowUiText::useDefaultFont();
}
// A banner from <prefix>/message, over whatever is running.
//
// Contributed by @SimonePDA. Drawn on top rather than instead of the pattern:
// the pattern keeps animating underneath, so a message is an interruption you
// can read and wait out, not a mode you have to leave.
//
// Uses the same wrapped-name helper the SELECT screen does, so a long message
// breaks the same way a long pattern name does — the panel is 64 px wide in
// portrait, which is about ten characters a line.
void drawBannerOverlay() {
if (!PatternflowBanner::active()) return;
char text[PatternflowBanner::MESSAGE_BYTES];
asciiFold(PatternflowBanner::message(), text, sizeof(text));
if (!text[0]) return;
// Portrait, like every other overlay; the caller is drawing in landscape.
dma_display->setRotation(1);
const int screenW = dma_display->width();
const int screenH = dma_display->height();
// A dim plate behind the words. Without it the message sits on top of a
// bright pattern and is unreadable exactly when it matters most.
int16_t x1, y1;
uint16_t tw, th;
PatternflowUiText::boundsWith(PatternflowUiText::useMessageFont, text, &x1, &y1, &tw, &th);
const int plateH = th + 24;
const int plateY = (screenH - plateH) / 2;
dma_display->fillRect(0, plateY, screenW, plateH, dma_display->color565(0, 0, 0));
dma_display->drawRect(0, plateY, screenW, plateH, pfLedC());
PatternflowUiText::drawWrappedMessage(text, screenH / 2,
dma_display->color565(255, 255, 255));
PatternflowUiText::useDefaultFont();
dma_display->setRotation(0);
}
// True while the device is showing its own UI over the pattern - the info
// screen, the knob map, the update screen, the brightness bar. Features get
// this through PFFeatureFrame::chromeVisible so a decorative overlay knows to
// stay off without reaching for these globals.
static inline bool chromeVisible() {
return oscInfoShowing || updateShowing || knobMapShowing || brightnessAdjusting;
}
// ── Building the input frame ───────────────────────────────────
//
// The seam every feature drives the device through, and the part of this file
// most worth understanding before changing anything.
//
// readInputFrame fills the frame from the PHYSICAL encoders only. Features
// add to it afterwards through PFFeatures::fillInput, and the three ways they
// can do that are deliberately different from each other:
//
// knobDeltas[i] encoder motion, accumulated. What a hand does.
// knobAudioActive/Value[i] an absolute continuous reading, 0..1, lerped
// into the parameter's range. A LANE: a level
// that is simply true right now, like a band of
// audio. Not a delta, and not a set value.
// paramAbsolute[i] an exact value, 0..1000. What a cue, a show or
// a remote sends when it means one number.
//
// PFParams::apply consumes them in the order paramAbsolute -> lane -> deltas
// and RETURNS EARLY at the first one that is active. That is why the frame is
// value-initialised where it is declared in loop(): on a build with no
// feature writing the lane fields, stack garbage in knobAudioActive makes
// apply() return before it ever reaches the encoders, and the knobs go dead.
// That shipped once. The braces on `InputFrame input{}` are load-bearing.
void readInputFrame(InputFrame& input) {
static long prevKnobs[4] = {0, 0, 0, 0};
input.now = (uint32_t)millis();
for (int i = 0; i < 4; i++) {
input.knobs[i] = getClicks(LOGICAL_TO_PHYSICAL_KNOB[i]);
}
// One detent, one step. There used to be a fast-spin multiplier here (x2 to
// x5 as the gap between detents shrank) so a knob could sweep a wide range
// quickly — but it made the knobs unpredictable on exactly the parameter
// that needs landing on a value: Origin's Mode knob picks a discrete preset,
// and a quick turn skipped five of them at a time. Tested against a
// no-acceleration build and the linear one won on feel.
//
// If some pattern's range now feels slow to cross, raise THAT pattern's step
// constant (d * 10 -> d * 25). Linear and predictable beats a curve that
// guesses at intent. Note OSC already bypassed the multiplier for the same
// reason — see below.
for (int i = 0; i < 4; i++) {
input.knobDeltas[i] = (int)(input.knobs[i] - prevKnobs[i]);
prevKnobs[i] = input.knobs[i];
}
// Physical encoder motion releases an absolute MQTT hold on that channel
// (Director / Show manager yield to hands-on control). Checked here, before
// OSC/MQTT/audio deltas are merged in, so only real knobs release — and
// core_mqtt's grace window ignores chatter right after an absolute set.
// The same motion dismisses a wake/snooze alarm cycle.
bool physMove = false;
for (int i = 0; i < 4; i++) {
if (input.knobDeltas[i] != 0) {
PatternflowBus::releaseAbsolute(i);
physMove = true;
}
}
if (physMove && !brightnessAdjusting && !oscInfoShowing && !knobMapShowing &&
!updateShowing) {
PFFeatures::onUserInput();
}
for (int i = 0; i < 4; i++) {
Button* button = logicalButton(i);
input.btnPressed[i] = button->pressed();
input.btnHeld[i] = button->isDown();
}
// Remote-driven virtual knob motion (each a no-op when its feature is
// compiled out). Added after acceleration so external automation moves at
// the raw 1×-per-detent rate, not amplified by the fast-spin curve.
// Absolute MQTT bus last, so it outranks everything above: held channels
// get their 0..1000 value and their deltas / audio flags cleared, which is
// what lets PFParams::apply pin the mapped parameter deterministically.
// Features may drive the lanes (a weather reading, a sensor). They run
// before the absolute bus below, which therefore still outranks them.
PFFeatures::fillInput(input);
PatternflowBus::fillAbsolute(input);
}
// An absolute lane, 0..1, turned into encoder motion.
//
// Named for audio because audio got here first, and that name was wrong: the
// weather feature drives these same lanes, and so may anything else. Nothing
// here knows or needs to know which feature put the value there.
//
// The motion exists so a pattern that only ever reads `knobDeltas` still
// reacts — which is why every pattern responds to these sources without
// carrying any code for them.
//
// It used to also clear `knobAudioActive`, and that quietly made the good
// path unreachable. `PFParams::apply` checks the flag FIRST and maps the
// level straight into the parameter's own range:
//
// if (input.knobAudioActive[i]) { *param = lerp(lo, hi, value); return; }
// if (input.knobDeltas[i] != 0) { *param += delta * step; }
//
// With the flag cleared, every source — browser, extension, the on-board
// microphone, the weather feature — fell through to the second line: unbounded
// accumulation, where
// the same sound gives a different result depending on what came before, and a
// dropped update is an error the parameter keeps forever.
//
// Leaving the flag set costs nothing and both paths coexist, because that
// first branch returns: a PFParams pattern reads the level and lands inside
// its declared range, a raw-delta pattern never looks at the flag and takes
// the motion. Nothing needs to choose.
//
// "Physical wins", and it has to keep winning for a moment afterwards.
//
// This used to suppress the lane only on the frame a delta arrived. That is
// not a release: the hand stops moving, the next frame has no delta, the lane
// takes the knob back and PFParams::apply drives the parameter straight to
// the mapped level again. From the front it reads as a knob pinned to a value
// it will not leave — turn it and it springs back — and with a source that is
// not really carrying audio, that value is whatever outMin happens to be.
//
// Reported from hardware as the device being locked up. It is not locked up:
// it is doing what it was told, by a lane nobody meant to leave in charge.
//
// So a physical delta buys the knob HANDS_OFF_MS to itself. Timed rather than
// permanent, because a lane is a continuous source and not a one-off command —
// the absolute bus is the one that gets released for good, in readInputFrame.
// A brush past an encoder must not silently stop the microphone working.
constexpr uint32_t LANE_HANDS_OFF_MS = 5000; // matches the brightness idle
void applyLaneMotion(InputFrame& input, bool enabled) {
static bool wasActive[4] = {false, false, false, false};
static float prevValue[4] = {0.0f, 0.0f, 0.0f, 0.0f};
static float residual[4] = {0.0f, 0.0f, 0.0f, 0.0f};
static uint32_t handsOffUntil[4] = {0, 0, 0, 0};
for (int i = 0; i < 4; i++) {
if (input.knobDeltas[i] != 0) handsOffUntil[i] = input.now + LANE_HANDS_OFF_MS;
// Signed compare so the wrap at 49 days is a non-event.
const bool heldByHand = (int32_t)(handsOffUntil[i] - input.now) > 0;
if (!enabled || !input.knobAudioActive[i] || heldByHand) {
wasActive[i] = false;
residual[i] = 0.0f;
input.knobAudioActive[i] = false;
continue;
}
float value = constrain(input.knobAudioValue[i], 0.0f, 1.0f);
if (!wasActive[i]) {
prevValue[i] = 0.5f;
residual[i] = 0.0f;
wasActive[i] = true;
}
// Full-rate: no MAX_DELTA clamp, so a fast swing lands this frame
// instead of crawling at a few clicks per frame. Residual carries the
// sub-click remainder so slow swings still move the knob.
float movement = (value - prevValue[i]) * PF_LANE_MOTION_SCALE + residual[i];
int delta = (int)roundf(movement);
residual[i] = movement - (float)delta;
if (fabsf(residual[i]) < 0.001f) residual[i] = 0.0f;
prevValue[i] = value;
input.knobDeltas[i] = delta;
}
}
// ── The frame ─────────────────────────────────────────────────
//
// Runs at ~60fps and everything in it is on the frame budget. The order is
// the contract features are written against, so moving a step is a breaking
// change even when it still compiles:
//
// 1. boot latch, sleep, and the network service calls
// 2. readInputFrame physical encoders into a zeroed frame
// 3. PFFeatures::fillInput features add deltas, lanes, absolutes
// 4. PFParams::apply one of those three wins, per knob
// 5. the pattern renders
// 6. PFFeatures::loop features get the finished frame and their own
// time slice
// 7. observeFrame / takePattern what happened, and who wants the panel
// 8. overlays draw on top
//
// Step 6 is not optional and is not a debug call. It went missing in v3.7.0,
// nothing failed to compile, and every feature's timer silently stopped for a
// release. If you are moving code in here, check it is still dispatched.
void loop() {
PFRuntime::LoopScope runtime([] { return loadInFlight; });
// Above the sleep block on purpose: a board that is asleep still has to be
// able to disarm the latch, or a device told to sleep within the first few
// seconds of boot would forget a pattern that never misbehaved.
clearPatternLatchIfStable();
// Wi-Fi, the console, Improv, OTA and the self-update's housekeeping are
// serviced by the network task on Core 0. Only if that task could not be
// created does loop() poll them itself, as it did before 3.9.1.
if (!PatternflowNetTask::isDualCoreActive()) {
PatternflowWifi::tick();
PatternflowHotspot::tick();
PatternflowImprov::handle();
PatternflowOta::handle();
PatternflowHttp::handle();
PatternflowWebUpdate::handle();
if (PatternflowNetTask::servicesReady) PatternflowNames::tick();
PFThumbs::serviceDisk();
}
// Once connected (or reconnected), start the network services.
if (PatternflowWifi::consumeJustConnected()) {
if (!PatternflowNetTask::servicesReady) PatternflowNames::begin();
PatternflowOta::begin();
PatternflowHomeHttp::begin();
PatternflowWebUpdate::begin();
PatternflowPatternsHttp::begin();
PatternflowStatusHttp::begin();
PatternflowDisplayHttp::begin();
PatternflowWifiHttp::begin();
PatternflowKnobsHttp::begin();
PatternflowHotspot::registerRoutes();
PatternflowNames::announce();
PFFeatures::onNetwork();
Serial.println("[NET] services started");
reportHeap("services up");
// Every route exists: the network task may serve the console now.
PatternflowNetTask::servicesReady = true;
}
// The frame boundary. Whatever a handler on the network core needed done
// on this task — evicting or restoring the module, walking the pattern
// list, touching a feature's client — runs here, before the frame.
PFLoopSync::service();
// A pattern the loader task finished with becomes the running one here,
// before anything below looks at what is running.
serviceAsyncLoad();
PFThumbs::service();
// Deferred module-list rebuilds requested by uploads/deletes — run here,
// outside any HTTP transaction.
PatternflowPatternsHttp::tick();
unsigned long now = millis();
// The one place dt is produced, so the one place it can be bounded. Every
// pattern integrates against it - position += velocity * dt - and a frame
// that took a quarter of a second moves everything a quarter of a second,
// in one step, which reads as the picture jumping rather than running.
//
// Those frames are not hypothetical and they are not rare: installing a
// pattern, an NVS commit and a thumbnail write all produce them. Measured
// on a panel during an ordinary upload session, runtime.maxUs reached
// 273,949 us against a 12,000 us nominal - 23x - and over50ms counted 1,661.
//
// 100 ms is twice the over50ms threshold this file already treats as long,
// so an honestly slow pattern passes through untouched and only a stall is
// caught. Clamping loses that time rather than spending it: animation runs
// slow for one frame instead of teleporting, which is the right way round.
// Do not remove this without watching a heavy pattern across an upload.
constexpr unsigned long DT_MAX_MS = 100;
unsigned long elapsed = now - lastMs;
if (elapsed > DT_MAX_MS) elapsed = DT_MAX_MS;
float dt = elapsed / 1000.0f;
lastMs = now;
runtime.housekeepingDone();
const uint32_t frameStartedUs = micros();
// Every feature's per-frame hook: the MQTT keepalive, the show player's tick,
// the weather scheduler, the audio websocket's accept/handshake pump, the
// on-board FFT.
//
// **This was missing.** The feature port removed the concrete calls that used
// to live above — correctly, they belong to the features now — and never
// added the dispatcher that replaced them, leaving two orphaned comments
// where the calls had been. Nothing failed loudly: `begin()` still ran, so
// :81 accepted TCP and every route answered; the websocket simply never
// completed a handshake because nobody was pumping it, and MQTT never
// reconnected. The features were all present, all initialised, and all
// frozen. Ahead of the render so a slow pattern cannot starve a socket.
{
PFFeatureFrame frame{dt, currentContentName(),
currentMode == MODE_RUNNING, chromeVisible(),
currentPatternIdx, (int)currentMode};
PFFeatures::loop(frame);
}
// Value-initialised, and it has to be. PFInputFrame is a plain ABI struct
// with no default member initialisers, and `knobAudioActive` /
// `knobAudioValue` are written only by a feature that drives a lane. The
// old default build always carried audio, whose fillInput wrote them every
// frame unconditionally, so nobody ever saw what happens without one:
// indeterminate memory reaching PFParams::apply, where a garbage `active`
// flag pins a pattern's parameter to a garbage value. Composing audio out
// is what surfaced it — /api/status reported lanes of 1.576 on a build with
// nothing driving them. Zeroing ~100 bytes a frame costs nothing.
InputFrame input{};
readInputFrame(input);
// One-shot click events for the mode buttons, consumed every frame so a
// click that lands outside its mode can't stay latched and fire later.
bool k1Clicked = logicalButton(0)->clicked();
bool k2Clicked = logicalButton(1)->clicked();
bool k3Clicked = logicalButton(2)->clicked();
bool k4Clicked = logicalButton(3)->clicked();
// Any click during a wake/snooze alarm cycle dismisses it — the person in
// the room outranks the schedule.
if (!brightnessAdjusting && !oscInfoShowing && !knobMapShowing &&
!updateShowing && (k1Clicked || k2Clicked || k3Clicked || k4Clicked)) {
PFFeatures::onUserInput();
}
// ── SLEEP ──────────────────────────────────────────────────────────
// Remote requests land here, not in the MQTT callback that produced them:
// stopping a DMA engine and reclocking the CPU inside client.loop() is the
// kind of thing this file's history warns about.
{
bool wantSleep;
if (PFFeatures::requestSleep(&wantSleep)) PatternflowSleep::request(wantSleep);
}
// Flashing beats sleeping, in both directions: a device that is being
// reflashed does not fall asleep underneath the upload, and one that is
// already asleep when an image starts arriving wakes up for it. Sleep stops
// the DMA engine, drops the CPU to 80 MHz and paces the loop — none of which
// an image being written to flash should have to put up with, and whoever is
// flashing wants the UPDATE screen anyway.
const bool flashInFlight = PatternflowWebUpdate::isUploading() ||
PatternflowWebUpdate::isRebootPending() ||
PatternflowOta::isInProgress();
if (!flashInFlight && PatternflowSleep::applyPending() &&
PatternflowSleep::isSleeping()) {
// Whatever the knobs read at this moment is "not touched since".
for (int i = 0; i < 4; i++) sleepKnobSnapshot[i] = input.knobs[i];
}
PFFeatures::onSleep(PatternflowSleep::isSleeping());
if (PatternflowSleep::isSleeping()) {
// input.knobs is the raw physical click count — the one field readInputFrame
// never merges OSC/MQTT/audio into. Comparing against the snapshot is what
// makes "a hand on the knob wakes it, a show streaming knob values does
// not" true rather than approximately true.
bool woke = flashInFlight;
if (PatternflowSleep::wakeableByInput()) {
for (int i = 0; i < 4; i++) {
if (input.knobs[i] != sleepKnobSnapshot[i] || input.btnPressed[i]) woke = true;
}
} else if (!woke) {
// Still inside the guard window: keep the snapshot moving with the hand,
// so the rest of the K1 turn that asked for sleep doesn't undo it.
for (int i = 0; i < 4; i++) sleepKnobSnapshot[i] = input.knobs[i];
}
if (woke) {
PatternflowSleep::wake();
// wake() put the panel back to currentBrightness, so the clamp's idea of
// what the panel is running at has to agree — otherwise the comparison
// below ("allowed != appliedBrightness") sees no change and the panel
// sits at full brightness while the clamp believes it is holding it down.
appliedBrightness = currentBrightness;
// Waking with a press means a button is still down, and it will cross the
// long-press threshold about a second from now — opening BRIGHTNESS or
// NETWORK on the way out of sleep. Retire the gesture: whatever is held
// has already been spent on waking up.
for (int i = 0; i < 4; i++) {
Button* b = logicalButton(i);
if (b->isDown()) b->longPressFired = true;
b->clicked();
}
// A console tab open somewhere has the resident module evicted, and with
// Origin the only compiled-in preset that is the ordinary case, not an
// exotic one. Left alone, waking would land on the CONSOLE PAUSED card
// until the 25 s idle timer fired — so a wake, from any of the three
// sources, counts as "give me the panel back". Same request the Play Now
// path makes: tick() does the reload from loop(), never from inside an
// HTTP transaction.
if (PatternflowPatternsHttp::isConsolePaused()) {
PatternflowPatternsHttp::requestReload();
}
// Every throttled screen redraws from scratch on the next frame; their
// "drawn at" timestamps are from before the sleep.
pausedDirty = true;
netInfoDirty = true;
knobMapDirty = true;
updateDirty = true;
}
// Asleep: no render, no flip, no power clamp. Everything above this point
// — Wi-Fi, OTA, HTTP, MQTT, web update — has already run, which is what
// keeps the device reachable while it sleeps.
//
// The delay is not politeness, it is most of the ESP-side saving: without
// it this becomes a busy loop polling sockets flat out at 80 MHz and the
// core never idles, so neither modem sleep nor the IDF's clock gating gets
// a chance to do anything. Yielding hands the time to the idle task
// instead. It costs up to SLEEP_LOOP_DELAY_MS of wake latency on a knob,
// which is well under what a hand can notice, and nothing at all on a
// flash — that wakes the device outright, just above.
if (!woke) delay(PatternflowSleep::LOOP_DELAY_MS);
return;
}
// Deferred NVS write for the selected pattern; no-op until the choice settles.
savePatternIfSettled();
if (!oscInfoShowing && !knobMapShowing && !updateShowing && logicalButton(0)->longPressed(MODE_HOLD_MS)) {
brightnessAdjusting = !brightnessAdjusting;
brightnessIdleAtMs = now;
Serial.printf(">>> BRIGHTNESS MODE: %s (%u%%)\n",
brightnessAdjusting ? "ON" : "OFF",
(currentBrightness * 100 + 127) / 255);
}
// Click = instant exit; no need to sit through the 1s hold.
if (brightnessAdjusting && k1Clicked) {
brightnessAdjusting = false;
Serial.println(">>> BRIGHTNESS MODE: OFF (click)");
}
if (brightnessAdjusting) {
int d = input.knobDeltas[0];
if (d != 0) {
int b = constrain((int)currentBrightness + d * 5, 5, 255);
if (b != (int)currentBrightness) {
currentBrightness = (uint8_t)b;
dma_display->setBrightness8(currentBrightness);
brightnessIdleAtMs = now;
brightnessDirty = true;
}
}
// Consume K1 input so the active pattern doesn't also react to it.
input.knobDeltas[0] = 0;
input.btnPressed[0] = false;
if ((now - brightnessIdleAtMs) > BRIGHTNESS_IDLE_MS) {
brightnessAdjusting = false;
Serial.println(">>> BRIGHTNESS MODE: OFF (idle)");
}
}
// Persist brightness once the adjustment has settled — avoids hammering
// NVS on every knob detent, or on every step of the console's slider
// (core_display_http.h moves the same three words a detent does, from the
// network core; if it moves them again while this writes, dirty stays set
// and the next pass writes the newer value).
if (brightnessDirty && !brightnessAdjusting &&
(now - brightnessIdleAtMs) > BRIGHTNESS_SAVE_DELAY_MS) {
const uint8_t saved = currentBrightness;
prefs.putUChar("brightness", saved);
if (currentBrightness == saved) brightnessDirty = false;
Serial.printf("[NVS] brightness saved: %u\n", saved);
}
// K2 longpress → enter/exit the NETWORK status + toggle screen.
// A K2 click while inside also exits, instantly (mirrors brightness mode).
if (!knobMapShowing && !updateShowing && logicalButton(1)->longPressed(MODE_HOLD_MS)) {
oscInfoShowing = !oscInfoShowing;
oscInfoIdleAtMs = now;
netInfoDirty = true;
Serial.printf(">>> NETWORK SCREEN: %s\n", oscInfoShowing ? "ON" : "OFF");
} else if (oscInfoShowing && k2Clicked) {
oscInfoShowing = false;
Serial.println(">>> NETWORK SCREEN: OFF (click)");
}
if (oscInfoShowing) {
// Turn K1 → sleep. Either direction: the screen is about to go dark, so
// "turn right for on" has nothing to mean here, and a person reaching for
// the wrong direction should still get what they asked for. Waking is any
// knob or button, handled at the top of the loop.
if (input.knobDeltas[0] != 0 && PatternflowSleep::isCompiledIn()) {
oscInfoShowing = false;
PatternflowSleep::request(true);
Serial.println(">>> SLEEP requested (K1 on NETWORK screen)");
}
// Turn K4 → hand off to the UPDATE screen, which arms the /update
// endpoint (see core_web_update.h — the arming IS the security model).
if (input.knobDeltas[3] != 0 && PatternflowWebUpdate::isCompiledIn()) {
oscInfoShowing = false;
updateShowing = true;
updateIdleAtMs = now;
updateDirty = true;
PatternflowWebUpdate::arm();
Serial.println(">>> UPDATE SCREEN: ON");
}
// Toggles use knob ROTATION, not clicks — so holding K2 to exit can't
// accidentally flip a setting. Turn right = ON, left = OFF.
// K2 and K3 turn the first and second rows the screen drew. Both come
// from features now — K2 used to be hard-wired to OSC back when OSC was
// core, which meant the screen and the knobs disagreed the moment any
// other feature moved out.
if (input.knobDeltas[1] != 0) { // K2 turn → first feature row
size_t a = PFFeatures::toggleableAt(0);
if (a < PF_FEATURE_COUNT) {
bool next = input.knobDeltas[1] > 0;
if (next != PFFeatures::runtimeEnabled(a)) {
PFFeatures::setRuntimeEnabled(a, next);
netInfoDirty = true;
Serial.printf("[FEATURE] %s runtime: %s\n", PFFeatures::shortName(a),
next ? "true" : "false");
}
}
oscInfoIdleAtMs = now;
}
if (input.knobDeltas[2] != 0) { // K3 turn → second feature row
size_t a = PFFeatures::toggleableAt(1);
if (a < PF_FEATURE_COUNT) {
bool next = input.knobDeltas[2] > 0;
if (next != PFFeatures::runtimeEnabled(a)) {
PFFeatures::setRuntimeEnabled(a, next);
netInfoDirty = true;
Serial.printf("[FEATURE] %s runtime: %s\n", PFFeatures::shortName(a),
next ? "true" : "false");
}
}
oscInfoIdleAtMs = now;
}
// Swallow all knob input so the pattern underneath doesn't react.
for (int i = 0; i < 4; i++) {
input.knobDeltas[i] = 0;
input.btnPressed[i] = false;
}
if ((now - oscInfoIdleAtMs) > OSC_INFO_IDLE_MS) {
oscInfoShowing = false;
Serial.println(">>> NETWORK SCREEN: OFF (idle)");
}
}
// K3 longpress → enter/exit the KNOB MAP screen. A K3 click inside exits.
if (!oscInfoShowing && !updateShowing && logicalButton(2)->longPressed(MODE_HOLD_MS)) {
knobMapShowing = !knobMapShowing;
knobMapIdleAtMs = now;
knobMapDirty = true;
Serial.printf(">>> KNOB MAP: %s\n", knobMapShowing ? "ON" : "OFF");
} else if (knobMapShowing && k3Clicked) {
knobMapShowing = false;
Serial.println(">>> KNOB MAP: OFF (click)");
}
if (knobMapShowing) {
// Light the digit of any knob being turned, then swallow the input so
// the pattern underneath (and OSC) don't see it.
for (int i = 0; i < 4; i++) {
if (input.knobDeltas[i] != 0) {
knobMapActiveAtMs[i] = now;
knobMapIdleAtMs = now;
knobMapDirty = true;
}
input.knobDeltas[i] = 0;
input.btnPressed[i] = false;
}
if ((now - knobMapIdleAtMs) > KNOB_MAP_IDLE_MS) {
knobMapShowing = false;
Serial.println(">>> KNOB MAP: OFF (idle)");
}
}
if (updateShowing) {
// Locked in while a flash is in flight or the reboot is pending — the
// device must not disarm (or navigate away) under an active upload.
bool busy = PatternflowWebUpdate::isUploading() ||
PatternflowWebUpdate::isRebootPending();
if (k4Clicked && !busy) {
updateShowing = false;
PatternflowWebUpdate::disarm();
Serial.println(">>> UPDATE SCREEN: OFF (click)");
}
// Swallow all knob input; any activity keeps the screen alive.
for (int i = 0; i < 4; i++) {
if (input.knobDeltas[i] != 0) updateIdleAtMs = now;
input.knobDeltas[i] = 0;
input.btnPressed[i] = false;
}
if (updateShowing && !busy && (now - updateIdleAtMs) > UPDATE_IDLE_MS) {
updateShowing = false;
PatternflowWebUpdate::disarm();
Serial.println(">>> UPDATE SCREEN: OFF (idle)");
}
}
if (!oscInfoShowing && !knobMapShowing && !updateShowing && logicalButton(3)->longPressed(MODE_HOLD_MS)) {
if (currentMode == MODE_RUNNING) {
PFFeatures::onUserInput();
// Entering SELECT is the strongest "hands on" signal there is — drop
// any leftover absolute holds so browsing can never fight a pinned
// channel's zeroed deltas.
PatternflowBus::clearAbsoluteAll();
// The running pattern's picture, before the first thumbnail is painted
// over its frame.
snapshotActiveIfRipe();
selectPending = false;
selectAccum = 0;
currentMode = MODE_SELECTING;
contentNoticeTimer = 0.0f;
// Physical escape hatch for the calibration overlay: whoever is at the
// device outranks a browser tab that may no longer exist.
CalibPattern::overrideOn = false;
Serial.printf(">>> SELECT MODE ENTERED: %s\n", patterns[currentPatternIdx].name);
} else {
// Leaving with the knob still on a choice that has not loaded: load it
// now rather than run whatever was underneath the thumbnail.
if (currentPatternIdx != activePatternIdx) activateWithSnapshot(currentPatternIdx);
selectPending = false;
currentMode = MODE_RUNNING;
dma_display->setRotation(0);
Serial.printf(">>> RUNNING MODE: %s\n", patterns[currentPatternIdx].name);
}
}
applyLaneMotion(
input,
currentMode == MODE_RUNNING && !brightnessAdjusting && !oscInfoShowing
);
// Everything above has had its say; this is the frame the pattern gets.
PatternflowBus::noteFinalFrame(input);
// Features that mirror device state see the finished frame here - every
// source merged, the absolute bus applied, exactly what the pattern gets.
{
PFFeatureFrame observed{dt, currentContentName(),
currentMode == MODE_RUNNING, chromeVisible(),
currentPatternIdx, (int)currentMode};
PFFeatures::observeFrame(input, observed);
}
// A feature may ask for a pattern; loading a module is the sketch's job,
// so it requests and we perform.
int featurePatternIdx;
bool featurePickWasAPerson = false;
// Not while an install batch is evicting modules — the OSC path used to
// check this and the feature path did not, which was a latent way to load
// a pattern into a half-emptied FATFS.
if (!PatternflowPatternsHttp::isConsolePaused() &&
PFFeatures::takePattern(&featurePatternIdx, &featurePickWasAPerson) &&
featurePatternIdx >= 0 && featurePatternIdx < NUM_PATTERNS) {
if (activateWithSnapshot(featurePatternIdx)) {
currentPatternIdx = featurePatternIdx;
currentMode = MODE_RUNNING;
// Hidden patterns arrive unannounced. This used to compare the name
// against "Black" - the show scheduler's night face - which was the
// core knowing one feature's pattern by name (check_boundaries.py
// caught it on its first run). `hidden` is the property that
// comparison was reaching for: a pattern nobody can browse to is not
// an arrival worth flashing a name for, whichever feature brought it.
if (!patterns[featurePatternIdx].hidden) {
contentNoticeTimer = CONTENT_NOTICE_SECONDS;
}
CalibPattern::overrideOn = false;
// A remote picker is a person choosing; remember it the way a knob
// turn is remembered. A show owns the panel and its cues are not
// choices, so they must not write NVS on every cue.
if (featurePickWasAPerson) notePatternChanged();
Serial.printf(">>> FEATURE pattern → %s\n", patterns[currentPatternIdx].name);
} else {
Serial.printf(">>> FEATURE pattern failed idx=%d\n", featurePatternIdx);
}
}
// Same contract as the OSC path above, fed by GET /api/patterns/select.
int httpPatternIdx;
if (!PFFeatures::patternClaimed() &&
PatternflowPatternsHttp::consumeSelectIdx(httpPatternIdx) &&
httpPatternIdx >= 0 && httpPatternIdx < NUM_PATTERNS &&
activateWithSnapshot(httpPatternIdx)) {
currentPatternIdx = httpPatternIdx;
currentMode = MODE_RUNNING;
contentNoticeTimer = CONTENT_NOTICE_SECONDS;
CalibPattern::overrideOn = false; // picking a pattern dismisses the test card
notePatternChanged();
Serial.printf(">>> HTTP pattern → %s\n", patterns[currentPatternIdx].name);
}
bool frameDrawn = true;
if (oscInfoShowing) {
if (netInfoDirty || (now - netInfoDrawnAtMs) >= NET_INFO_REDRAW_MS) {
drawNetworkInfo();
netInfoDrawnAtMs = now;
netInfoDirty = false;
} else {
// Nothing redrawn — skip the flip below so the displayed buffer
// stays untouched (flipping to a stale back buffer flickers).
frameDrawn = false;
}
} else if (knobMapShowing) {
// Redraw on activity or at the slow poll (so highlights also fade out);
// same anti-flicker scheme as the NETWORK screen above.
if (knobMapDirty || (now - knobMapDrawnAtMs) >= NET_INFO_REDRAW_MS) {
drawKnobMap();
knobMapDrawnAtMs = now;
knobMapDirty = false;
} else {
frameDrawn = false;
}
} else if (updateShowing || PatternflowWebUpdate::isRebootPending()) {
// Same throttled-redraw scheme as the NETWORK screen, for every state
// of the card — idle, receiving, done, failed. The upload handler runs
// on the network core and only reports (uploadPctShown, updateDirty);
// the drawing stays here, on the one task that owns the panel. The
// isRebootPending() arm covers always-armed uploads that land while a
// pattern is running: the DONE / REBOOTING card shows for the ~1.2 s
// before restart instead of snapping back to the pattern.
if (updateDirty || (now - updateDrawnAtMs) >= NET_INFO_REDRAW_MS) {
drawUpdateScreen(PatternflowWebUpdate::isUploading() ? uploadPctShown : -1);
updateDrawnAtMs = now;
updateDirty = false;
} else {
frameDrawn = false;
}
} else if (currentMode == MODE_RUNNING && CalibPattern::overrideOn) {
// Calibration overlay: the test card draws instead of the pattern, which
// freezes underneath (not updated) and resumes exactly where it was once
// the tuner dismisses the card. Runs even while the console has the module
// evicted — the card is compiled in and needs none of the module's RAM.
// K1/K2 still work on the card for whoever is standing at the panel.
pausedDirty = true;
CalibPattern::update(dt, input);
CalibPattern::draw();
if (brightnessAdjusting) {
drawBrightnessNotice();
}
} else if (currentMode == MODE_RUNNING && loadInFlight) {
// The pattern is on its way in on the other core. Its picture if it has
// one; otherwise the frame already on the panel stays until it lands.
pausedDirty = true;
static int previewTarget = -1;
static uint32_t previewAtMs = 0;
if (previewTarget != loadTargetIdx || !canvasShowsThumb || now - previewAtMs >= 64) {
previewTarget = loadTargetIdx;
previewAtMs = now;
if (!drawPatternThumbnail(loadTargetIdx, false)) frameDrawn = false;
} else frameDrawn = false;
} else if (currentMode == MODE_RUNNING && activePatternIdx < 0) {
// Same throttled-redraw scheme as the info screens: this is static text and
// repainting it every loop races the panel scanout.
if (pausedDirty || (now - pausedDrawnAtMs) >= NET_INFO_REDRAW_MS) {
drawPausedScreen();
pausedDrawnAtMs = now;
pausedDirty = false;
} else {
frameDrawn = false;
}
} else if (currentMode == MODE_RUNNING) {
pausedDirty = true;
updateActivePattern(dt, input);
drawActivePattern();
canvasShowsThumb = false;
drawBannerOverlay();
{
PFFeatureFrame frame{dt, currentContentName(), true, chromeVisible(),
currentPatternIdx, (int)currentMode};
PFFeatures::drawOverlay(frame);
}
if (contentNoticeTimer > 0.0f) {
drawContentNotice();
contentNoticeTimer -= dt;
}
// Shown for the whole time the mode is active — this IS the mode
// indicator; when it disappears you're back to normal knob control.
if (brightnessAdjusting) {
drawBrightnessNotice();
}
} else {
// Features may own an alternate SELECT mode (e.g. Sequences playlist).
// If any returns true, skip the default K4 pattern browse this frame.
bool selectFeatureOwnsBrowse = PFFeatures::handleSelectInput(input);
int selectSteps = 0;
if (!selectFeatureOwnsBrowse && input.knobDeltas[3] != 0) {
selectAccum += input.knobDeltas[3];
selectSteps = selectAccum / SELECT_DETENTS_PER_STEP; // truncates toward zero
selectAccum -= selectSteps * SELECT_DETENTS_PER_STEP;
}
if (selectFeatureOwnsBrowse) selectAccum = 0;
if (selectSteps != 0) {
currentPatternIdx += selectSteps;
// Floored modulo: OSC /knob/4/delta can deliver a delta more negative
// than -NUM_PATTERNS in one frame, and C++'s % keeps the sign — a plain
// "+= NUM_PATTERNS once" would leave a negative index into patterns[].
currentPatternIdx = ((currentPatternIdx % NUM_PATTERNS) + NUM_PATTERNS) % NUM_PATTERNS;
// Step over hidden entries in whichever direction the knob is going.
// Bounded by NUM_PATTERNS so an all-hidden list cannot spin forever.
{
int step = selectSteps > 0 ? 1 : -1;
for (int guard = 0; guard < NUM_PATTERNS && patterns[currentPatternIdx].hidden; guard++) {
currentPatternIdx =
((currentPatternIdx + step) % NUM_PATTERNS + NUM_PATTERNS) % NUM_PATTERNS;
}
}
// Not loaded here. It was measured (2026-09-06): landing on a module
// costs its read, relocate and setup(), and setup() is the pattern's
// own precomputation - 10 ms for a light one, 2.3 s for "Branched
// flow", 2.9 s for "Two-stream". Loading on every detent froze the
// loop for that long while the encoder kept counting, and the
// accumulated detents landed all at once when the loop came back:
// a panel that sat still for two seconds and then jumped five
// patterns. So the knob moves the highlight and nothing else; the load
// waits until the knob has rested, and the thumbnail stands in.
selectPending = true;
selectMovedAtMs = now;
// Marked, not written: savePatternIfSettled() holds off until SELECT is
// left and the choice has stopped moving.
notePatternChanged();
Serial.printf("SELECTING: %s\n", patterns[currentPatternIdx].name);
}
if (selectPending && (uint32_t)(now - selectMovedAtMs) >= SELECT_SETTLE_MS) {
// One attempt per rest: a module that fails to load is not retried
// every frame. While the load runs the panel keeps showing the last
// frame it was handed - the thumbnail - so the seconds a heavy setup()
// takes read as the picture holding, not the panel freezing.
selectPending = false;
if (currentPatternIdx != activePatternIdx) activateWithSnapshot(currentPatternIdx);
}
// Live preview behind the overlay so you can see what you're choosing.
// Render the pattern in the native landscape orientation (rotation 0, same
// as running mode) so present() fills the whole panel — then switch to the
// device's portrait orientation (rotation 1) for the SELECT text so it
// reads upright, exactly as before. Feed a neutral input frame (no knob
// deltas / buttons) so browsing with K4 doesn't drive the pattern's own
// parameters — it just animates over time.
dma_display->setRotation(0);
if (currentPatternIdx == activePatternIdx && !loadInFlight) {
InputFrame preview = {};
preview.now = input.now;
for (int i = 0; i < 4; i++) preview.knobs[i] = input.knobs[i];
updateActivePattern(dt, preview);
drawActivePattern();
canvasShowsThumb = false;
} else {
// The highlight is on a pattern that is not loaded (yet): its picture
// from the last time it ran, or nothing but the name until it has.
drawPatternThumbnail(currentPatternIdx, true);
}
dma_display->setRotation(1);
if (!PFFeatures::drawSelect()) {
drawSelectingMode();
PFFeatures::decorateSelect();
}
if (brightnessAdjusting) {
drawBrightnessNotice(); // mode indicator, same as in RUNNING
}
dma_display->setRotation(0); // back to landscape for the next frame
}
// Frame rate, sampled only on frames that actually rendered a pattern — the
// info screens deliberately skip drawing, and counting those would report a
// rate the panel never showed. Exponential average so /status reads steady
// instead of jittering with every frame.
if (frameDrawn) {
dma_display->flipDMABuffer();
uint32_t frameUs = micros() - frameStartedUs;
renderFrameUs = renderFrameUs ? (renderFrameUs * 7 + frameUs) / 8 : frameUs;
// Total power clamp. The frame that just went out is the one whose demand
// was measured, so the answer lands on the next one — invisible at this
// frame rate, and it costs no second pass over the pixels.
//
// currentBrightness stays exactly what the person chose: only the value
// handed to the panel is capped, so turning the clamp off (or pointing a
// pattern at something darker) restores their setting with no further
// input from them.
uint8_t allowed = PatternflowPower::allow(currentBrightness);
if (allowed != appliedBrightness) {
appliedBrightness = allowed;
dma_display->setBrightness8(allowed);
}
}
}