diff --git a/BUILD_GUIDE.md b/BUILD_GUIDE.md index ffeb86c..f986699 100644 --- a/BUILD_GUIDE.md +++ b/BUILD_GUIDE.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e1d7c9..614860b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `​/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. diff --git a/README.md b/README.md index eff81e9..e05d183 100644 --- a/README.md +++ b/README.md @@ -7,165 +7,157 @@ [![Crowd Supply](https://img.shields.io/badge/Crowd_Supply-Launching_Q4_2026-d4502b?style=flat-square)](https://www.crowdsupply.com/engmung/patternflow) [![Discord](https://img.shields.io/discord/1497757947827327067?style=flat-square&logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/Vr9QtsxeTk) -> ⚠️ **Photosensitivity Warning** -> Patternflow displays rapidly changing light patterns that may trigger seizures in people with photosensitive epilepsy. Viewer discretion is advised. If you experience any discomfort, stop use immediately. -

- Patternflow — a hand turns a knob and the light reshapes - Patternflow — the film: what it is, how it's built, and why + Patternflow: a hand turns a knob and the light reshapes + Patternflow, the film: what it is, how it's built, and why

- ▶ the reel — 400k+ views on Instagram  ·  the film — the whole story, on YouTube + ▶ the reel: 400k+ views on Instagram  ·  the film: the whole story, on YouTube

-**Patternflow is an open-source LED synthesizer.** Play light patterns with your fingertips — four physical knobs reshape generative light on an LED matrix in real time. A pattern is just a small file, so **every Patternflow plays every pattern we make.** +> **Photosensitivity warning.** Patternflow displays rapidly changing light patterns that may trigger seizures in people with photosensitive epilepsy. If you experience any discomfort, stop use immediately. -### Four ways in +**Patternflow is an open-source LED synthesizer.** You play it with four knobs. 8,192 pixels across a 128 × 64 matrix respond the instant you turn one; nothing is pre-rendered, every frame is computed live on the device. A pattern is just a small file, so every Patternflow plays every pattern anyone makes. -- 🔨 **Build it** — the **[Full Build Guide](BUILD_GUIDE.md)** (official PCB + 3D-printed enclosure), or solder-free with the **[Breadboard Build Guide](https://patternflow.work/build/breadboard)**. Every other route is in the **[Assembly Map](docs/assembly/README.md)**. **Never soldered before? You'll be fine** — it's all big through-hole joints, every first-timer who has built one finished it without trouble, and most of them came back saying the soldering was the fun part. One build on the map was its maker's first-ever soldering job. -- 📦 **Get one ready-made** — the **[Crowd Supply campaign](https://www.crowdsupply.com/engmung/patternflow)** launches **Q4 2026**. Subscribe on the page and you'll hear the moment it opens. -- 🎛️ **Try it right now** — the **[Live Editor](https://patternflow.work/pattern)** is a working Patternflow in your browser. No hardware, no install, no account. -- 🌀 **Share what you make** — the **[Community](https://community.patternflow.work/community)** is where patterns get published, remixed and forked. The **[Pattern Guide](PATTERN_GUIDE.md)** walks the whole loop: make a pattern, verify it on your device, share it back. +## Where it began -> **Moving fast.** [**v3.3.0 is out**](https://github.com/engmung/Patternflow/releases/tag/v3.3.0) — the panel's color is tunable while you look at it, and ramps interpolate in OKLab instead of collapsing to grey. [v3.2.0](https://github.com/engmung/Patternflow/releases/tag/v3.2.0) made patterns install over Wi-Fi as `.pfm` modules, no reflash. The v3 generation also brought hybrid USB-C / screw-terminal power, zero SMD passives, a snap-fit enclosure, and a [video-first build guide](BUILD_GUIDE.md). On v2.x hardware? Everything you need stays bundled at [v2.1.0](https://github.com/engmung/Patternflow/releases/tag/v2.1.0). Follow the [changelog](CHANGELOG.md) and the [journal](https://patternflow.work/journal) for what's current. +Almost every screen around us is a playback device. It takes in content and shows it to us, and we sit in front of it and watch. Patternflow is different: touch it and it answers in that instant, and nobody plays the same thing twice. -## At a glance +In January 2026 I stopped by the Nam June Paik Art Center, which happened to be running an event marking twenty years since Paik's death. That is how I ended up standing in front of *Participation TV* (1963): you speak into a microphone and the picture on the screen moves with your voice. It turned the viewer from someone watching into someone taking part. I spoke into it, and my voice became the picture. -Hardware, website, community, and where people actually talk — the whole ecosystem on one napkin: +Two months later I connected an LED matrix and a potentiometer for the first time. -

- Hand-drawn map of the Patternflow ecosystem: the device, the GitHub files and Crowd Supply routes to it; patternflow.work with the Live Editor, Pattern Lab and Community wall; Discord, Instagram and GitHub Discussions; and the Workshop, where project talk is gathering -

+**Paik invited audiences to experience art. Patternflow invites you to make it.** -## Quick facts +I put that first pattern, *Patternflow: Origin*, on an LED matrix with four knobs and posted it online. Some people asked how to buy it; far more asked how to build it. So everything went public: schematics, firmware, 3D models, the build guide, and the whole workflow for making new patterns. -| | | -| :--- | :--- | -| **Display** | 128 × 64 HUB75 RGB LED matrix, P2.5 (320 × 160 mm) | -| **Brain** | ESP32-S3-WROOM-1 **N16R8** (16 MB flash, 8 MB PSRAM) — standalone, no sending card | -| **Input** | 4× EC11 rotary encoders with push-switch; long-press encoder 4 to switch patterns | -| **Power** | 5 V over USB from any power bank — about **4 h per 10,000 mAh** at max brightness (see [runtime](#power--runtime)) | -| **Size / weight** | 245 × 325 × 36 mm (9.6 × 12.8 × 1.4 in) · 933 g (2.06 lb) | -| **Firmware** | Arduino-compatible C++, modular pattern architecture, runtime switching (no reflash) | -| **Flashing** | Everything from the browser — one USB flash the first time, then it's all Wi-Fi: patterns install as modules in seconds, full firmware builds land wirelessly too. Arduino IDE only for firmware development or other matrix resolutions | -| **Connectivity** | Wi-Fi — bidirectional OSC (Ableton/Max/TouchDesigner), MQTT, and audio-react WebSocket · USB | -| **Build** | ~1 h hands-on (≈30 min soldering + ≈30 min assembly — first-build friendly) + ~10 h 3D printing · US$100–200 in parts ([BOM](BUILD_GUIDE.md#1-bill-of-materials-bom)) | -| **License** | MIT (firmware & web code) · CC-BY-SA 4.0 (hardware, docs, bundled patterns) · community patterns are licensed by their authors ([summary](docs/LICENSE-SUMMARY.md)) | - -### Power & runtime +## The community responded -Patternflow runs off any standard USB power bank that can supply a couple of amps at 5 V — no wall adapter needed, so it stays portable. Rule of thumb: **about 4 hours per 10,000 mAh at maximum brightness**, and most patterns draw less. +

+ Seven Patternflows built around the world: different enclosures, colors and materials, two of them in DJ booths +

+

+ Builds by Nath (UK), shooter (Poland), day (France), Enerjoy (Norway), SimonePDA (UAE), Nick (USA), and Jon (USA). +

-
-Measured runtimes +Builders from around the world keep adding pins to the **[build map](https://patternflow.work/inside)**. Someone built theirs as a Raspberry Pi port with a 64 × 32 matrix, another layered paper and acrylic into a diffuser, others cut their own enclosures. Two of them live in DJ booths. -In testing, a 40,000 mAh bank dropped about 13% over 2 hours at **full brightness**, which is roughly **15 hours on a full charge**: +I started this alone. It hasn't worked that way for a while now. The web-upload feature wasn't mine: someone suggested it, built a proof of concept, and shared the code, and two days later it shipped as a main feature. The `.pfm` pattern modules came the same way. A contributor designed the loader, built it, and proved it on hardware before proposing it. They're core to the instrument now, and you couldn't take them out. -| Power bank | Approx. runtime (max brightness) | -| :--- | :--- | -| 10,000 mAh | ~4 hours | -| 20,000 mAh | ~8 hours | -| 40,000 mAh | ~15 hours | +The clearest case started on the other side of the world. A media art collective took Patternflow's pattern modules, added MIDI control and a microphone, and built a browser-based instrument of their own: **[MOTIFLOW](https://azmano.art/portfolio-item/motiflow/)**, recently shown in a gallery. Patternflow stopped being only an instrument people play. It became a work that makes other works. -These are worst-case numbers measured with a bright pattern at maximum brightness. Lowering the brightness (long-press encoder 1) extends runtime well beyond these figures. +**Patternflow isn't a thing one person made anymore. It's a thing we're all making.** -
+## Make it yours -## Patterns +### 1. The device -The **[Live Editor](https://patternflow.work/pattern)** opens with a preset library of **42 patterns** — months of daily pattern-making, each loadable in one click and remixable right in the browser. The stock firmware flashes to the device straight from the browser; your own remixes travel through Pattern Lab and land on the device as Wi-Fi modules (see below). +The **[Full Build Guide](BUILD_GUIDE.md)** covers the official route: the custom PCB and a 3D-printed enclosure. Don't want to order a board? The **[Breadboard Build Guide](https://patternflow.work/build/breadboard)** wires the same electronics with jumper wires instead, and that's a real Patternflow, not a temporary prototype. Every other combination is on the **[Assembly Map](docs/assembly/README.md)**. Parts run about US$100–200 ([BOM](BUILD_GUIDE.md#1-bill-of-materials-bom)), it's all big through-hole joints, and every first-timer who has built one finished it. Most came back saying the soldering was the fun part. -On the device, patterns live on the filesystem rather than inside the program. The firmware compiles in **Origin alone**, as the failsafe a board can always boot into, and everything else installs as **`.pfm` modules over Wi-Fi** — up to 128 of them, no reflash needed. Keeping the set out of the image is what freed the internal memory the rest of the firmware needed, and it means adding a pattern never costs a firmware update. +**When yours lights up, tell us.** Post it in [Discord](https://discord.gg/Vr9QtsxeTk) or [Discussions](../../discussions) and it goes on the build map, a globe of Patternflows where each pin carries its build's story. The map is for the ones people made themselves: every pin is someone who built one from these files, in their own material, wherever they are. The goal is simple: cover it with pins. -A new board is therefore nearly empty, so a set ships with it: the **Basics pack**, 33 patterns, at the top of the [decks shelf](https://community.patternflow.work/community/decks). One click installs the lot — no account and no build queue. Drop the `.zip` on your board's Patterns page yourself if you'd rather, and any deck someone publishes installs the same way. +

+ Build map: a globe of Patternflows built around the world, with the story of every build +

-- It boots into **Origin** — concentric sine waves sampled by an emergent grid. -- **Long-press encoder 4** to cycle through the patterns on the device. +Rather have it arrive assembled, tested, and flashed? The **[Crowd Supply campaign](https://www.crowdsupply.com/engmung/patternflow)** launches **Q4 2026**. Subscribe on the page and you'll hear the moment it opens. -## Make your own patterns +### 2. Patterns -[patternflow.work](https://patternflow.work) is not a companion page — it's half the instrument. The whole journey from idea to hardware runs in the browser, no toolchain: prompt templates designed for AI coding assistants (Claude, ChatGPT, Gemini, etc.) do the writing, you do the deciding. The **Live Editor** is where you start, **Pattern Lab** is where a pattern becomes firmware, and the **[Community](https://community.patternflow.work/community)** is where it goes when it's done. +You don't need hardware to start. The **[Live Editor](https://patternflow.work/pattern)** is a full Patternflow simulator: turn the virtual knobs and it behaves like the real thing. Patterns start as JavaScript in the browser, and you don't have to write it yourself. Click **Copy creation prompt**, paste it into your AI assistant with a description of the look you want, paste back the code it returns, and play. No GLSL, no rendering pipeline: the template handles the encoder mapping, brightness curve and pixel buffer, and you describe the visuals.

- Live Editor — a full Patternflow simulator in the browser, code beside the device + Live Editor: a full Patternflow simulator in the browser, code beside the device

-**Start in the [Live Editor](https://patternflow.work/pattern)** — a full Patternflow simulator, and the quick taste. Turn the virtual knobs and the on-screen device behaves exactly like the real one, down to the encoder detents. You don't need the hardware to begin: +**[Pattern Lab](https://patternflow.work/pattern-lab)** is the full studio, and where a pattern reaches the hardware. Generate variations in batches, shape color ramps, retune knob ranges, then send it to your device: it builds into a small `.pfm` module and installs over Wi-Fi, about ten seconds start to finish. No cable, no reflash, no IDE. -1. Click **Copy creation prompt** and paste it into your AI assistant along with a description of the look you want. -2. Paste the JavaScript it returns into the editor and turn the virtual knobs to test the pattern live. +When it looks right, publish it to the **[Community](https://community.patternflow.work/community)**. More than a hundred patterns are up already and the range keeps widening, from quiet waves to chaos-theory studies, every one written as code and every one playable under the same four knobs. Visuals you can hold while they run are young territory; there is still room for real invention here. Collect patterns into a deck and send it to your board in one click. Browsing needs no account, and publishing asks a username and password, no email. The **[Pattern Guide](PATTERN_GUIDE.md)** walks the whole loop. -The Live Editor is where you find out you want to make patterns. It stays deliberately light — the stock firmware flashes straight from it, and everything below is where the making happens. - -**Go deeper in [Pattern Lab](https://patternflow.work/pattern-lab)** — the full studio, and where patterns reach the hardware: - -- Generate variations **in batches**, in-app (bring your own free Gemini key) or via copy-paste prompts. -- Shape **color ramps**, retune knob ranges, and compose for **custom matrix sizes** — the ranges you set ride along into the device and the shared code. -- **Send to your device in seconds** — a pattern builds into a small `.pfm` module on the server and installs **over Wi-Fi**: no cable, no reflash, no IDE. Firmware updates are their own thing now, at [patternflow.work/update](https://patternflow.work/update) — a pattern never needs one. -- **Verify, then share** — try the pattern on your own device first, then publish it with its hardware header attached. It lands on the wall wearing the `.h` badge, and the next person can flash it without thinking. The **[Pattern Guide](PATTERN_GUIDE.md)** covers the whole flow. - -**From the Arduino IDE** — only needed for firmware feature development or targeting an LED matrix with a different resolution. Open `firmware/patternflow/patternflow.ino`, drop the C++ into `presets/preset_.h` **as-is**, add the namespace to `presetPatterns[]` in `pattern_registry.h`, and flash — that compiles the pattern into the image, which is now reserved for the failsafe rather than the usual way in. (For just adding a pattern you don't need any of this — build it as a `.pfm` module and send it over Wi-Fi.) See [`firmware/patternflow/README.md`](firmware/patternflow/README.md). +

+ The community wall: dozens of patterns by different authors, each one playing, with a deck being assembled along the bottom +

-No GLSL or rendering pipeline knowledge needed. The template handles the encoder mapping, brightness curve, and HUB75 buffer interface; you describe the visuals. +Patterns don't stay on the wall, either. New pattern studies go up on **[Instagram](https://www.instagram.com/patternflow.work)** almost daily. Publish something that stands out and it can get featured there, or ask for a collab on Instagram and we'll post yours together. -Patterns installed as `.pfm` modules sit on their own partition and survive any reflash — a firmware update rewrites the program and leaves your patterns, your Wi-Fi and your storage untouched. To rebuild the shipped pack from the repo's own preset sources, see [`firmware/toolchain/make_pack.py`](firmware/toolchain/make_pack.py). +### 3. The ecosystem -## Community +Patterns are the surface. Underneath is an instrument still being designed, in the open, by whoever shows up. This is the whole system on one napkin:

- The community wall — patterns people made, playing live + Hand-drawn map of the Patternflow ecosystem: the device, the GitHub files and Crowd Supply routes to it; patternflow.work with the Live Editor, Pattern Lab and Community wall; Discord, Instagram and GitHub Discussions; and the Workshop, where project talk is gathering

-The **[Community](https://community.patternflow.work/community)** is where patterns live — and the wall plays them **live**. Hover a card and the pattern runs; scroll on it and its knobs turn, the same four knobs as the hardware. Open one to read its code, take it into Pattern Lab to remix, and publish it back — the fork is recorded, so you can always see what grew out of what. All of this works without an account. +The **[Workshop](https://community.patternflow.work/community/workshop)** is where the project's future is worked out. It's a map of directions Patternflow could take: a wired OSC version, laser-cut enclosures, bigger panels. Anyone can pin themselves to a direction, say what they're working on, and start a thread. Not a roadmap handed down; a map people stand on. -**From the wall to your device in seconds.** Collect patterns into a **deck** — a setlist of up to twenty — and `SEND TO MY BOARD` builds them into Wi-Fi modules and installs them with no cable, no reflash. Decks can be shared too: browse the sets other people curated and send one straight to your own device. Patterns carrying a hardware-verified `.h` header are badged, with a filter for when you want something to flash right now. The **[Pattern Guide](PATTERN_GUIDE.md)** walks every step. +It's already moving in directions I didn't choose. One contributor is building out MQTT further than I've had time to follow: units reaching each other across a network, one person's playing coming out of somebody else's device. Another is working on sound on the board itself. Another is taking the TouchDesigner link further than I did. None of it was assigned. -**The Workshop is where the project's future is worked out — in the open.** It's a map of directions Patternflow could take — a wired OSC version, laser-cut enclosures, bigger panels — and anyone can pin themselves to a direction, say what they're working on, and start a thread. Not a roadmap handed down; a map people stand on. It's young and evolving fast, and it matters more than its size suggests: it's how this project decides where to go next. +**[CONTRIBUTING.md](CONTRIBUTING.md)** covers how contributions flow. Questions and ideas go to **[Discussions](../../discussions)** or the **[Discord](https://discord.gg/Vr9QtsxeTk)**, whichever you can reach. -Signing in is username and password only — no email — so it never routes through a service that might be blocked where you are. - -New pattern studies also go up on [Instagram](https://www.instagram.com/patternflow.work) almost daily, and the [Discord](https://discord.gg/Vr9QtsxeTk) **patterns** channel remains a good place to hang around and talk about them. **Come for a pattern you saw on a post, stay to share your own.** +## Quick facts -## Built in the open +| | | +| :--- | :--- | +| **Display** | 128 × 64 HUB75 RGB LED matrix, P2.5 (320 × 160 mm) | +| **Brain** | ESP32-S3-WROOM-1 **N16R8** (16 MB flash, 8 MB PSRAM) · standalone, no sending card | +| **Input** | 4× EC11 rotary encoders with push-switch; long-press encoder 4 to switch patterns | +| **Power** | 5 V over USB from any power bank; about **4 h per 10,000 mAh** at max brightness with a typical pattern (see [runtime](#power--runtime)) | +| **Size / weight** | 245 × 325 × 36 mm (9.6 × 12.8 × 1.4 in) · 933 g (2.06 lb) | +| **Firmware** | Arduino-compatible C++, modular pattern architecture, runtime switching (no reflash) | +| **Flashing** | Everything from the browser: one USB flash the first time, then it's all Wi-Fi. Patterns install as modules in seconds, full firmware builds land wirelessly too. Arduino IDE only for firmware development or other matrix resolutions | +| **Connectivity** | Wi-Fi: bidirectional OSC (Ableton/Max/TouchDesigner), MQTT, and audio-react WebSocket · USB | +| **Build** | ~1 h hands-on (≈30 min soldering + ≈30 min assembly, first-build friendly) + ~10 h 3D printing · US$100–200 in parts ([BOM](BUILD_GUIDE.md#1-bill-of-materials-bom)) | +| **License** | MIT (firmware & web code) · CC-BY-SA 4.0 (hardware, docs, bundled patterns) · community patterns are licensed by their authors ([summary](docs/LICENSE-SUMMARY.md)) | -Two public records show the project working — who is building, and what it costs to keep alive. +### Power & runtime -

- Build map — a globe of Patternflows built around the world, with the story of every build -

+Patternflow runs off any standard USB power bank that can supply a couple of amps at 5 V. No wall adapter needed, so it stays portable. Rule of thumb: **about 4 hours per 10,000 mAh** at maximum brightness with a typical pattern. -**[Build map](https://patternflow.work/inside)** — a globe of Patternflows built around the world, each pin carrying its build's story, and the goal is simple: cover it with pins. The build map is also what *we* means in "every pattern we make": every pin is a person who built one from these files, in their own material, wherever they are. If you've made one, share it in Discord and it goes on the map. +
+Measured runtimes -**[Journal](https://patternflow.work/journal)** — Patternflow is treated as art, so the whole process is documented transparently: the events, the emotions, and the thinking behind every step, written up at least once a week since the beginning. Including the parts that went badly. If you want to know why this project exists — and what it costs to keep it alive — start there. +In testing, a 40,000 mAh bank dropped about 13% over 2 hours at **full brightness**, which is roughly **15 hours on a full charge**: -## OSC, MQTT & audio-react +| Power bank | Approx. runtime (max brightness) | +| :--- | :--- | +| 10,000 mAh | ~4 hours | +| 20,000 mAh | ~8 hours | +| 40,000 mAh | ~15 hours | -**Bidirectional OSC.** Over Wi-Fi, Patternflow speaks OSC in both directions: knob turns, button presses, and pattern switches stream out to a remote host (Ableton Live, Max/MSP, TouchDesigner — anything that speaks OSC), and incoming OSC messages drive the device exactly like physical encoder motion. Play Patternflow as a controller for your set, let your set drive the light, or both at once. If you play MIDI instruments, this will feel like home. For Ableton Live Suite there's a ready-made Max for Live bridge in [`integrations/ableton`](integrations/ableton) — click Connect, map the four knobs to any Live parameters, done. The wire protocol is documented in [`docs/osc-spec.md`](docs/osc-spec.md). +These were measured with a bright generative pattern at maximum brightness, which is how the device is normally used. Lowering the brightness (long-press encoder 1) extends runtime well beyond these figures. -**MQTT.** Patternflow also speaks MQTT, both ways, on the broker you already run. Knob turns and pattern changes publish as they happen; messages coming the other way move the knobs and switch patterns exactly as a hand on the encoder would. Two boards pointed at the same broker follow each other, which is the short version of why it exists — and it puts the device on the same bus as the rest of a home or venue setup, so Home Assistant, Node-RED or a lighting desk can drive it without anything Patternflow-specific in the middle. Point it at a broker on the device's own **MQTT** page; a pattern can be addressed by name or by slug. Contributed by **[@SimonePDA](https://github.com/SimonePDA)** (Simone Majocchi), along with the browser-side zip unpacking that makes pattern packs install in one click. +**A pattern that fills the screen with white is a different story.** The same 40,000 mAh bank dropped 9% in 30 minutes: about 26.6 W, or roughly 4.8 A at 5 V, which is above what a typical USB-A port is rated for and enough to make the connector warm. That works out to ~5.5 hours on a 40,000 mAh bank. A total-pixel-power clamp is going into the firmware so this can't happen; until then, avoid near-full-white patterns on a power bank, or use a supply and cable rated for the current. -**Audio-react.** Patternflow can also react to browser audio: the experimental Chrome/Edge extension in [`tools/patternflow-audio-extension`](tools/patternflow-audio-extension) captures the current tab's audio, analyzes four FFT bands, and sends lightweight WebSocket knob values to the device. The firmware converts those into virtual encoder motion, so every encoder-driven pattern responds — no audio code needed in the patterns themselves. +
-## How it's built +## On the device -Patternflow is built around a standalone ESP32-S3 driving a HUB75 RGB LED matrix at low resolution — each pixel reads as a discrete point of light, with its own brightness and color. Four rotary encoders feed firmware written in Arduino-compatible C++ around a modular pattern architecture: each pattern is a self-contained module with its own setup, update, and draw routines, while the shared framework handles input, LED rendering, mode transitions, and color calibration. The PCB was designed by the artist; the enclosure is 3D-printed by default, with stainless steel, transparent acrylic, and laser-cut variations in progress. +A new board boots into **Origin**, concentric sine waves sampled by an emergent grid, and long-pressing encoder 4 cycles through whatever else is installed. Patterns live on the filesystem rather than inside the program. The firmware compiles in Origin alone, as the failsafe a board can always boot into, and everything else installs as **`.pfm` modules over Wi-Fi**, up to 128 of them, no reflash. Adding a pattern never costs a firmware update, and a firmware update never touches your patterns, your Wi-Fi, or your storage. -## The idea +A new board is therefore nearly empty, so a set ships with it: the **Basics pack**, 33 patterns, at the top of the [decks shelf](https://community.patternflow.work/community/decks). One click installs the lot, no account and no build queue, or drop the `.zip` on your board's Patterns page yourself. The [Live Editor](https://patternflow.work/pattern) opens with its own preset library of 42 patterns, each loadable and remixable in the browser. -**Make it easy. Make it fun. Make it yours.** Interactive media art usually demands serious capital, custom engineering, and years of specialized skill — an entry fee paid long before anyone gets to participate in anything. Patternflow removes it: the hardware files, firmware, 3D models, browser editor, and AI prompts are all public, so anyone can build their own, make their own patterns, test them in the browser, and load them onto the device. +The Arduino IDE is only needed for firmware feature development or targeting an LED matrix with a different resolution; see [`firmware/patternflow/README.md`](firmware/patternflow/README.md) and [Custom Patterns](firmware/CUSTOM_PATTERNS.md). To rebuild the shipped pack from the repo's own preset sources, see [`firmware/toolchain/make_pack.py`](firmware/toolchain/make_pack.py). -This is a contemporary reinterpretation of Nam June Paik's *Participation TV* (1963). Paik let the audience change the image; Patternflow lets you make it — and give it away. That is the step after participation: from intervening in a work to making, modifying, and sharing it. +## OSC, MQTT & audio-react

- Hand-drawn map of the long view: sound, light and touch meeting in one instrument — sound artists, visual artists and makers each entering through their own sense — with Instagram as the proving ground, and the same patterns scaling from the device through bigger panels and snap-on modules to installations and commissioned spaces + Patternflow beside an Ableton Live session, with the Patternflow Bridge Max for Live device open and the four knobs mapped to Live parameters

-The device is instrument no. 1 of a longer practice — a lab for interactive visual patterns. The same pattern that runs on a hand-built device can run on a bigger panel, a laser-cut variant, an installation wall: **the same pattern, any surface.** Solid lines exist today; the dashed ones are directions, drawn in the open on the [Workshop](https://community.patternflow.work/community/workshop). +**Bidirectional OSC.** Over Wi-Fi, Patternflow speaks OSC in both directions: knob turns, button presses, and pattern switches stream out to a remote host (Ableton Live, Max/MSP, TouchDesigner, anything that speaks OSC), and incoming OSC messages drive the device exactly like physical encoder motion. Play Patternflow as a controller for your set, let your set drive the light, or both at once. If you play MIDI instruments, this will feel like home. For Ableton Live Suite there's a ready-made Max for Live bridge in [`integrations/ableton`](integrations/ableton). Click Connect, map the four knobs to any Live parameters, done. The wire protocol is documented in [`docs/osc-spec.md`](docs/osc-spec.md). + +**MQTT.** Patternflow also speaks MQTT, both ways, on the broker you already run. Knob turns and pattern changes publish as they happen; messages coming the other way move the knobs and switch patterns exactly as a hand on the encoder would. Two boards pointed at the same broker follow each other, which is the short version of why it exists. It also puts the device on the same bus as the rest of a home or venue setup, so Home Assistant, Node-RED or a lighting desk can drive it without anything Patternflow-specific in the middle. Point it at a broker on the device's own **MQTT** page; a pattern can be addressed by name or by slug. Contributed by **[@SimonePDA](https://github.com/SimonePDA)** (Simone Majocchi), along with the browser-side zip unpacking that makes pattern packs install in one click. + +**Audio-react.** Patternflow can also react to browser audio. The experimental Chrome/Edge extension in [`tools/patternflow-audio-extension`](tools/patternflow-audio-extension) captures the current tab's audio, analyzes four FFT bands, and sends lightweight WebSocket knob values to the device. The firmware converts those into virtual encoder motion, so every encoder-driven pattern responds, with no audio code needed in the patterns themselves. -So Patternflow is not a single luminous object. It is a living system in which a physical experience extends outward into open-source making and community creation. **We're not making art easier to watch. We're making it easier to make.** The longer version lives in the [manifesto](docs/manifesto.md) and the [journal](https://patternflow.work/journal). +## How it's built + +Patternflow is built around a standalone ESP32-S3 driving a HUB75 RGB LED matrix at low resolution, where each pixel reads as a discrete point of light with its own brightness and color. Four rotary encoders feed firmware written in Arduino-compatible C++ around a modular pattern architecture: each pattern is a self-contained module with its own setup, update, and draw routines, while the shared framework handles input, LED rendering, mode transitions, and color calibration. I designed the PCB myself; the enclosure is 3D-printed by default, with stainless steel, transparent acrylic, and laser-cut variations in progress. ## Repository & documentation +> **Moving fast.** [v3.3.0 is out](https://github.com/engmung/Patternflow/releases/tag/v3.3.0): the panel's color is tunable while you look at it, and ramps interpolate in OKLab instead of collapsing to grey. [v3.2.0](https://github.com/engmung/Patternflow/releases/tag/v3.2.0) made patterns install over Wi-Fi as `.pfm` modules, no reflash. On v2.x hardware? Everything you need stays bundled at [v2.1.0](https://github.com/engmung/Patternflow/releases/tag/v2.1.0). Follow the [changelog](CHANGELOG.md) and the [journal](https://patternflow.work/journal) for what's current. + | Folder | Contents | | :--- | :--- | | `firmware/` | Arduino code for ESP32-S3, the custom pattern template, and the toolchain that builds patterns into `.pfm` modules and packs | @@ -173,39 +165,37 @@ So Patternflow is not a single luminous object. It is a living system in which a | `web/` | Next.js site (landing, Live Editor, Pattern Lab, community, browser flasher & build server, journal) | | `docs/` | Assembly map, build-guide media, manifesto, license summary | | `tools/` | Desktop-side helpers, including the audio-react browser extension | -| `integrations/` | Host-software bridges — Ableton Live / Max for Live (OSC knob mapping) | +| `integrations/` | Host-software bridges: Ableton Live / Max for Live (OSC knob mapping) | -**Docs:** [Full Build Guide](BUILD_GUIDE.md) · [Pattern Guide](PATTERN_GUIDE.md) · [Assembly Map](docs/assembly/README.md) · [Custom Patterns](firmware/CUSTOM_PATTERNS.md) · [Changelog](CHANGELOG.md) · [License Summary](docs/LICENSE-SUMMARY.md) +**Docs:** [Full Build Guide](BUILD_GUIDE.md) · [Pattern Guide](PATTERN_GUIDE.md) · [Assembly Map](docs/assembly/README.md) · [Custom Patterns](firmware/CUSTOM_PATTERNS.md) · [Manifesto](docs/manifesto.md) · [Changelog](CHANGELOG.md) · [License Summary](docs/LICENSE-SUMMARY.md) **Links:** [patternflow.work](https://patternflow.work) · [Community](https://community.patternflow.work/community) · [Crowd Supply](https://www.crowdsupply.com/engmung/patternflow) · [Releases](../../releases) · [Discord](https://discord.gg/Vr9QtsxeTk) · [Instagram](https://www.instagram.com/patternflow.work) ## Contributing -Builds, documentation fixes, part sourcing tips, and custom patterns are all welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for how contributions flow (including the inbound = outbound pattern licensing). - -Patterns go to the **[Community](https://community.patternflow.work/community)**, builds and questions to **[Discussions](../../discussions)** or the **[Discord](https://discord.gg/Vr9QtsxeTk)** — whichever you can reach. Show a build in Discussions or Discord to get it pinned on the [build map](https://patternflow.work/inside). +Builds, documentation fixes, part sourcing tips, and custom patterns are all welcome. **[CONTRIBUTING.md](CONTRIBUTING.md)** covers how contributions flow, including the inbound = outbound pattern licensing. ## Story so far | When | Milestone | | :--- | :--- | -| **Jan 2026** | *Patternflow: Origin* — the first work as a new media artist, built around 3D-printed forms and the seed of what became Patternflow · visited the Nam June Paik Art Center | +| **Jan 2026** | *Patternflow: Origin*, the first work as a new media artist, built around 3D-printed forms and the seed of what became Patternflow · visited the Nam June Paik Art Center | | **Mar 2026** | The Origin pattern ran on a physical LED matrix with four knobs for the first time | -| **Apr 2026** | Instagram and the Arduino subreddit responded strongly — **150,000+ views and 3,700 upvotes** — and the community asked for the files, not a product, so Patternflow went open source · first PCB fabricated *(sponsored by PCBWay)* · website live | +| **Apr 2026** | Instagram and the Arduino subreddit responded strongly (**150,000+ views and 3,700 upvotes**), and the community asked for the files, not a product, so Patternflow went open source · first PCB fabricated *(sponsored by PCBWay)* · website live | | **May 2026** | Reached **100 GitHub stars** · the first collaborator joined · Crowd Supply agreement · Discord community growing | | **Jun 2026** | [Crowd Supply pre-launch page](https://www.crowdsupply.com/engmung/patternflow) live, backed by countless refinements toward mass production · Instagram passed **1,000 followers** · first community-made pattern shared | | **Jul 2026** | Refining the design for mass production · growing an active community · outreach and promotion | -| **Aug 2026** | Community rebuilt end to end — the live wall, decks, and the Workshop — patterns now install as Wi-Fi modules, and the [Pattern Guide](PATTERN_GUIDE.md) documents the whole loop · Crowd Supply launch prep in full swing | +| **Aug 2026** | Community rebuilt end to end (the live wall, decks, and the Workshop) · patterns now install as Wi-Fi modules, and the [Pattern Guide](PATTERN_GUIDE.md) documents the whole loop · Crowd Supply launch prep in full swing | | **Next** | Launch the Crowd Supply campaign (**Q4 2026**) at the lowest sustainable price · send Patternflow further out into the world · collaborate with more artists · earn academic recognition | -| **2028** | Grow Patternflow into a self-sustaining community and ecosystem — then move on to the next project | +| **2028** | Grow Patternflow into a self-sustaining community and ecosystem, then move on to the next project | -📖 Longer write-ups and the full story behind each step live on the **[journal](https://patternflow.work/journal)**. +Longer write-ups and the full story behind each step live on the **[journal](https://patternflow.work/journal)**: the whole process, written up at least weekly since the beginning, the thinking and the emotions included, and the parts that went badly too. If you want to know why this project exists and what it costs to keep alive, start there. ### Sponsor PCBWay -Patternflow's PCB fabrication and 3D-printed enclosure are sponsored by **[PCBWay](https://www.pcbway.com/)**. The first PCB came back clean and on-spec, ordering was straightforward, and the team has been genuinely responsive throughout — the support that made these milestones possible. +Patternflow's PCB fabrication and 3D-printed enclosure are sponsored by **[PCBWay](https://www.pcbway.com/)**. The first PCB came back clean and on-spec, ordering was straightforward, and the team has been genuinely responsive throughout. That support made these milestones possible. First Patternflow PCB fabricated by PCBWay @@ -213,13 +203,13 @@ Patternflow's PCB fabrication and 3D-printed enclosure are sponsored by **[PCBWa ## License -The SPDX header inside a file is the authority — folders are not license boundaries. Full breakdown in the **[License Summary](docs/LICENSE-SUMMARY.md)**. +The SPDX header inside a file is the authority; folders are not license boundaries. Full breakdown in the **[License Summary](docs/LICENSE-SUMMARY.md)**. -- Firmware & web code — **MIT** ([LICENSE-MIT](./LICENSE-MIT)) -- Hardware, designs & docs — **CC-BY-SA 4.0** ([LICENSE-CC-BY-SA](./LICENSE-CC-BY-SA)) -- Bundled patterns (the presets shipped in the firmware and the editor) — **CC-BY-SA 4.0**, per-file SPDX headers. -- Patterns contributed *to the repository* — inbound = outbound: by sending a pattern as a PR, issue or Discord post you license it under CC-BY-SA 4.0, attribution kept in the code header (no CLA). See [CONTRIBUTING.md](CONTRIBUTING.md). -- Patterns published *to the Community* — **licensed by their author**, either **CC-BY-SA 4.0** (default) or **CC-BY-4.0**. Both permit commercial use with credit; CC-BY-SA additionally requires adaptations to stay under the same license. A fork can never be looser than what it came from, and carries a `Based on:` credit to the original author in its source. +- Firmware & web code: **MIT** ([LICENSE-MIT](./LICENSE-MIT)) +- Hardware, designs & docs: **CC-BY-SA 4.0** ([LICENSE-CC-BY-SA](./LICENSE-CC-BY-SA)) +- Bundled patterns (the presets shipped in the firmware and the editor): **CC-BY-SA 4.0**, per-file SPDX headers. +- Patterns contributed *to the repository*: inbound = outbound. By sending a pattern as a PR, issue or Discord post you license it under CC-BY-SA 4.0, attribution kept in the code header (no CLA). See [CONTRIBUTING.md](CONTRIBUTING.md). +- Patterns published *to the Community*: **licensed by their author**, either **CC-BY-SA 4.0** (default) or **CC-BY-4.0**. Both permit commercial use with credit; CC-BY-SA additionally requires adaptations to stay under the same license. A fork can never be looser than what it came from, and carries a `Based on:` credit to the original author in its source. "Patternflow" is a trademark of SeungHun Lee. diff --git a/docs/media/community-builds.jpg b/docs/media/community-builds.jpg new file mode 100644 index 0000000..0958de9 Binary files /dev/null and b/docs/media/community-builds.jpg differ diff --git a/docs/media/community-library.png b/docs/media/community-library.png new file mode 100644 index 0000000..6cea90a Binary files /dev/null and b/docs/media/community-library.png differ diff --git a/docs/media/daw-ableton.jpg b/docs/media/daw-ableton.jpg new file mode 100644 index 0000000..e57ee72 Binary files /dev/null and b/docs/media/daw-ableton.jpg differ diff --git a/firmware/patternflow/abi/pf_abi.h b/firmware/patternflow/abi/pf_abi.h index 0c85ef4..56ba69b 100644 --- a/firmware/patternflow/abi/pf_abi.h +++ b/firmware/patternflow/abi/pf_abi.h @@ -4,8 +4,25 @@ // - 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 @@ -13,6 +30,13 @@ #include #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 @@ -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. diff --git a/firmware/patternflow/abi/pf_module.h b/firmware/patternflow/abi/pf_module.h index 7400009..61ae521 100644 --- a/firmware/patternflow/abi/pf_module.h +++ b/firmware/patternflow/abi/pf_module.h @@ -30,6 +30,7 @@ #include #include "pf_abi.h" +#include "pf_params.h" #ifndef PF_PANEL_W #error "PF_PANEL_W not defined - build modules with -DPF_PANEL_W=" @@ -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; \ diff --git a/firmware/patternflow/abi/pf_params.h b/firmware/patternflow/abi/pf_params.h new file mode 100644 index 0000000..e40a3f9 --- /dev/null +++ b/firmware/patternflow/abi/pf_params.h @@ -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 +#include + +#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 +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 +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 +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 +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 diff --git a/firmware/patternflow/pattern_registry.h b/firmware/patternflow/pattern_registry.h index 4b51b58..ecc99c6 100644 --- a/firmware/patternflow/pattern_registry.h +++ b/firmware/patternflow/pattern_registry.h @@ -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. @@ -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. @@ -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]), }; } } diff --git a/firmware/patternflow/patternflow.ino b/firmware/patternflow/patternflow.ino index 7924fe6..d078d94 100644 --- a/firmware/patternflow/patternflow.ino +++ b/firmware/patternflow/patternflow.ino @@ -138,14 +138,26 @@ void setup() { // into whatever the K2 info screen was last set to. PatternflowOsc::setRuntimeEnabled(prefs.getBool("osc_runtime", true)); PatternflowAudio::setRuntimeEnabled(prefs.getBool("audio_runtime", true)); - // Same idea for the MQTT role picked on /mqtt. Defaults to Off, so a - // device that has never been told otherwise never dials a broker. - PatternflowMqtt::setRole( - (PatternflowMqtt::Role)prefs.getUChar("mqtt_role", PatternflowMqtt::ROLE_OFF)); - // And the broker itself, typed in on the same page. Left at the compiled-in - // defaults (empty, unless someone set PF_MQTT_* in their secrets header) - // when nothing has been saved. + // MQTT channel + role from /mqtt. loadConfig first (broker + prefix + + // channel), then apply the saved role under the channel's constraints + // (Ch1–4 force Subscriber). Defaults stay Off, so a device that has never + // been told otherwise never dials a broker. PatternflowMqtt::loadConfig(); + { + auto savedRole = (PatternflowMqtt::Role)prefs.getUChar( + "mqtt_role", PatternflowMqtt::ROLE_OFF); + auto ch = PatternflowMqtt::currentChannel(); + if (savedRole == PatternflowMqtt::ROLE_OFF || + ch == PatternflowMqtt::CHANNEL_OFF) { + PatternflowMqtt::applyChannel(PatternflowMqtt::CHANNEL_OFF, + PatternflowMqtt::ROLE_OFF); + } else { + PatternflowMqtt::applyChannel(ch, savedRole); + } + // Director mode (local authoring broker) survives reboots the same way + // the normal broker does. + PatternflowMqtt::applySavedMode(); + } // 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() @@ -651,6 +663,14 @@ void readInputFrame(InputFrame& input) { 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. + for (int i = 0; i < 4; i++) { + if (input.knobDeltas[i] != 0) PatternflowMqtt::releaseAbsolute(i); + } + for (int i = 0; i < 4; i++) { Button* button = logicalButton(i); input.btnPressed[i] = button->pressed(); @@ -680,6 +700,11 @@ void readInputFrame(InputFrame& input) { input.knobAudioActive[i] = PatternflowAudio::isActive(i); input.knobAudioValue[i] = PatternflowAudio::value(i); } + + // 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. + PatternflowMqtt::fillAbsolute(input); } // Legacy absolute audio-react path for older clients that still send k=N,v=F. diff --git a/firmware/patternflow/presets/preset_0510.h b/firmware/patternflow/presets/preset_0510.h index 4d675ad..86ae501 100644 --- a/firmware/patternflow/presets/preset_0510.h +++ b/firmware/patternflow/presets/preset_0510.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0510 { const char* NAME = "0510"; const char* const KNOB_LABELS[4] = {"hueBase", "speed", "scale", "distortion"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float hueBase = 0.0f; @@ -48,13 +50,11 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.hueBase += input.knobDeltas[0] * 0.05f; - if (params.hueBase < 0) params.hueBase += 1.0f; - params.hueBase = fmodf(params.hueBase, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.hueBase, 0.05f); - params.speed = max(0.0f, params.speed + input.knobDeltas[1] * 0.05f); - params.scale = max(0.1f, params.scale + input.knobDeltas[2] * 0.05f); - params.distortion = max(0.0f, params.distortion + input.knobDeltas[3] * 0.05f); + PFParams::apply(input, 1, ¶ms.speed, 0.0f, 10.0f, 0.05f); + PFParams::apply(input, 2, ¶ms.scale, 0.1f, 10.0f, 0.05f); + PFParams::apply(input, 3, ¶ms.distortion, 0.0f, 10.0f, 0.05f); params.timeAcc += dt * params.speed; } diff --git a/firmware/patternflow/presets/preset_0511.h b/firmware/patternflow/presets/preset_0511.h index fe6a233..c04edcb 100644 --- a/firmware/patternflow/presets/preset_0511.h +++ b/firmware/patternflow/presets/preset_0511.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0511 { const char* NAME = "0511"; const char* const KNOB_LABELS[4] = {"hueBase", "speed", "rowHeight", "segWidth"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float hueBase = 0.0f; @@ -49,11 +51,11 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.hueBase = fmodf((float)((params.hueBase + input.knobDeltas[0] * 0.05f)), (float)(1.0f)); + PFParams::applyUnit(input, 0, ¶ms.hueBase, 0.05f); if (params.hueBase < 0) params.hueBase += 1.0f; - params.speed = max(0.0f, params.speed + input.knobDeltas[1] * 0.05f); - params.rowHeight = constrain(params.rowHeight + input.knobDeltas[2] * 0.5f, 4.0f, 16.0f); - params.segWidth = constrain(params.segWidth + input.knobDeltas[3] * 1.0f, 8.0f, 48.0f); + PFParams::apply(input, 1, ¶ms.speed, 0.0f, 10.0f, 0.05f); + PFParams::apply(input, 2, ¶ms.rowHeight, 4.0f, 16.0f, 0.5f); + PFParams::apply(input, 3, ¶ms.segWidth, 8.0f, 48.0f, 1.0f); params.timeAcc += dt * params.speed; } diff --git a/firmware/patternflow/presets/preset_0512.h b/firmware/patternflow/presets/preset_0512.h index c89c26c..d4810c3 100644 --- a/firmware/patternflow/presets/preset_0512.h +++ b/firmware/patternflow/presets/preset_0512.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0512 { const char* NAME = "0512"; const char* const KNOB_LABELS[4] = {"hueBase", "speed", "petals", "fold"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float hueBase = 0.0f; @@ -48,11 +50,11 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.hueBase = fmodf((float)((params.hueBase + input.knobDeltas[0] * 0.05f)), (float)(1.0f)); + PFParams::applyUnit(input, 0, ¶ms.hueBase, 0.05f); if (params.hueBase < 0) params.hueBase += 1.0f; - params.speed = max(0.0f, params.speed + input.knobDeltas[1] * 0.05f); - params.petals = constrain(params.petals + input.knobDeltas[2] * 0.5f, 3.0f, 16.0f); - params.fold = constrain(params.fold + input.knobDeltas[3] * 0.05f, 0.0f, 5.0f); + PFParams::apply(input, 1, ¶ms.speed, 0.0f, 10.0f, 0.05f); + PFParams::apply(input, 2, ¶ms.petals, 3.0f, 16.0f, 0.5f); + PFParams::apply(input, 3, ¶ms.fold, 0.0f, 5.0f, 0.05f); params.timeAcc += dt * params.speed; } diff --git a/firmware/patternflow/presets/preset_0513.h b/firmware/patternflow/presets/preset_0513.h index 9444c63..10a3685 100644 --- a/firmware/patternflow/presets/preset_0513.h +++ b/firmware/patternflow/presets/preset_0513.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0513 { const char* NAME = "0513"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float hue = 0.0f; @@ -74,15 +76,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - float d0 = 0.0f, d1 = 0.0f, d2 = 0.0f, d3 = 0.0f; - if (true) { - d0 = input.knobDeltas[0]; d1 = input.knobDeltas[1]; - d2 = input.knobDeltas[2]; d3 = input.knobDeltas[3]; - } - params.hueT = wrap01(params.hueT + d0 * 0.012f); - params.speedT = constrain(params.speedT + d1 * 0.018f, 0.0f, 1.0f); - params.modeT = constrain(params.modeT + d2 * 0.018f, 0.0f, 1.0f); - params.freqT = constrain(params.freqT + d3 * 0.018f, 0.0f, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.hueT, 0.012f); + PFParams::apply(input, 1, ¶ms.speedT, 0.0f, 1.0f, 0.018f); + PFParams::apply(input, 2, ¶ms.modeT, 0.0f, 1.0f, 0.018f); + PFParams::apply(input, 3, ¶ms.freqT, 0.0f, 1.0f, 0.018f); float s = constrain(dt * 7.5f, 0.0f, 1.0f); params.hue = mixHue(params.hue, params.hueT, s); params.speed = mix(params.speed, params.speedT, s); diff --git a/firmware/patternflow/presets/preset_0514.h b/firmware/patternflow/presets/preset_0514.h index 85eeb09..2988397 100644 --- a/firmware/patternflow/presets/preset_0514.h +++ b/firmware/patternflow/presets/preset_0514.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0514 { const char* NAME = "0514"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -64,17 +66,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0515.h b/firmware/patternflow/presets/preset_0515.h index 79e6368..60d0759 100644 --- a/firmware/patternflow/presets/preset_0515.h +++ b/firmware/patternflow/presets/preset_0515.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0515 { const char* NAME = "0515"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -57,17 +59,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0515_3.h b/firmware/patternflow/presets/preset_0515_3.h index 4c40851..94db0f8 100644 --- a/firmware/patternflow/presets/preset_0515_3.h +++ b/firmware/patternflow/presets/preset_0515_3.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern05153 { const char* NAME = "0515-3"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -63,17 +65,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0515_4.h b/firmware/patternflow/presets/preset_0515_4.h index 68a920a..ac986cf 100644 --- a/firmware/patternflow/presets/preset_0515_4.h +++ b/firmware/patternflow/presets/preset_0515_4.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern05154 { const char* NAME = "0515-4"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -60,17 +62,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0518.h b/firmware/patternflow/presets/preset_0518.h index a9276c9..ec4a79a 100644 --- a/firmware/patternflow/presets/preset_0518.h +++ b/firmware/patternflow/presets/preset_0518.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0518 { const char* NAME = "0518"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -82,17 +84,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0519_1.h b/firmware/patternflow/presets/preset_0519_1.h index dcbc830..f94a40f 100644 --- a/firmware/patternflow/presets/preset_0519_1.h +++ b/firmware/patternflow/presets/preset_0519_1.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern05191 { const char* NAME = "0519-1"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -56,17 +58,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0520.h b/firmware/patternflow/presets/preset_0520.h index 620dbdc..e5e66e2 100644 --- a/firmware/patternflow/presets/preset_0520.h +++ b/firmware/patternflow/presets/preset_0520.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0520 { const char* NAME = "0520"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -61,17 +63,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0521.h b/firmware/patternflow/presets/preset_0521.h index 4e86aba..6f76c9e 100644 --- a/firmware/patternflow/presets/preset_0521.h +++ b/firmware/patternflow/presets/preset_0521.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0521 { const char* NAME = "0521"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -61,17 +63,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0522.h b/firmware/patternflow/presets/preset_0522.h index e6f162e..0d6df5b 100644 --- a/firmware/patternflow/presets/preset_0522.h +++ b/firmware/patternflow/presets/preset_0522.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0522 { const char* NAME = "0522"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -61,17 +63,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0527.h b/firmware/patternflow/presets/preset_0527.h index 7d014ad..00fcbd8 100644 --- a/firmware/patternflow/presets/preset_0527.h +++ b/firmware/patternflow/presets/preset_0527.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0527 { const char* NAME = "0527"; const char* const KNOB_LABELS[4] = {"Cluster Weight", "Rotation Rate", "Max Distance", "Normal Depth"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.4f; @@ -47,17 +49,10 @@ namespace Pattern0527 { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); params.nodeCount = 4 + floorf(params.rawKnob0 * 6); params.speed = params.rawKnob1; diff --git a/firmware/patternflow/presets/preset_0528.h b/firmware/patternflow/presets/preset_0528.h index 197c846..ef9c1a3 100644 --- a/firmware/patternflow/presets/preset_0528.h +++ b/firmware/patternflow/presets/preset_0528.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0528 { const char* NAME = "0528"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -57,17 +59,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0531.h b/firmware/patternflow/presets/preset_0531.h index d02bcd8..a4eed71 100644 --- a/firmware/patternflow/presets/preset_0531.h +++ b/firmware/patternflow/presets/preset_0531.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0531 { const char* NAME = "0531"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -57,17 +59,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0601.h b/firmware/patternflow/presets/preset_0601.h index 92dd3bd..37d4690 100644 --- a/firmware/patternflow/presets/preset_0601.h +++ b/firmware/patternflow/presets/preset_0601.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0601 { const char* NAME = "0601"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -57,17 +59,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0602.h b/firmware/patternflow/presets/preset_0602.h index 25a0f1c..8eb469f 100644 --- a/firmware/patternflow/presets/preset_0602.h +++ b/firmware/patternflow/presets/preset_0602.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0602 { const char* NAME = "0602"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -56,17 +58,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0628.h b/firmware/patternflow/presets/preset_0628.h index dc0b822..d340c03 100644 --- a/firmware/patternflow/presets/preset_0628.h +++ b/firmware/patternflow/presets/preset_0628.h @@ -14,11 +14,13 @@ #include "../src/core_canvas.h" #include "../src/core_math.h" #include "../src/core_color.h" +#include "../src/core_params.h" namespace RetroDigitalTapestry { const char* NAME = "Retro Digital Tapestry"; const char* const KNOB_LABELS[4] = {"Cell Scale", "Speed", "Logic Mode", "Wave Mod"}; +constexpr bool ABSOLUTE_READY = true; float cellScale = -0.101f; float speed = 1.99f; @@ -36,21 +38,13 @@ void setup() { } void update(float dt, const InputFrame& input) { - cellScale += input.knobDeltas[0] * 0.05f; - if (cellScale < -0.101f) cellScale = -0.101f; - if (cellScale > 0.5f) cellScale = 0.5f; + PFParams::apply(input, 0, &cellScale, -0.101f, 0.5f, 0.05f); - speed += input.knobDeltas[1] * 0.1f; - if (speed < 0.1f) speed = 0.1f; - if (speed > 5.0f) speed = 5.0f; + PFParams::apply(input, 1, &speed, 0.1f, 5.0f, 0.1f); - logicMode += input.knobDeltas[2] * 0.05f; - if (logicMode < 0.0f) logicMode = 0.0f; - if (logicMode > 1.001f) logicMode = 1.001f; + PFParams::apply(input, 2, &logicMode, 0.0f, 1.001f, 0.05f); - waveMod += input.knobDeltas[3] * 0.05f; - if (waveMod < 0.0f) waveMod = 0.0f; - if (waveMod > 3.0f) waveMod = 3.0f; + PFParams::apply(input, 3, &waveMod, 0.0f, 3.0f, 0.05f); timeAcc += dt * speed * 2.0f; } diff --git a/firmware/patternflow/presets/preset_0629.h b/firmware/patternflow/presets/preset_0629.h index c440516..928332e 100644 --- a/firmware/patternflow/presets/preset_0629.h +++ b/firmware/patternflow/presets/preset_0629.h @@ -15,11 +15,13 @@ #include "../src/core_encoders.h" #include "../src/core_canvas.h" #include "../src/core_math.h" +#include "../src/core_params.h" namespace ChromaticAberrationVortexPattern { const char* NAME = "Chromatic Vortex"; const char* const KNOB_LABELS[4] = {"Split Dist", "Swirl Speed", "Wave Density", "Color Shift"}; +constexpr bool ABSOLUTE_READY = true; const float CHROMATIC_VORTEX_SPLIT_MIN = 0.0f; const float CHROMATIC_VORTEX_SPLIT_MAX = 1.0f; @@ -57,19 +59,19 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.split += input.knobDeltas[0] * CHROMATIC_VORTEX_SPLIT_STEP; - params.split = fmodf(params.split, 1.0f); - if (params.split < 0.0f) params.split += 1.0f; + PFParams::applyUnit(input, 0, ¶ms.split, CHROMATIC_VORTEX_SPLIT_STEP); - params.speed += input.knobDeltas[1] * CHROMATIC_VORTEX_SPEED_STEP; - params.speed = constrain(params.speed, CHROMATIC_VORTEX_SPEED_MIN, CHROMATIC_VORTEX_SPEED_MAX); + // Real ranges in the apply call so the 0..1000 absolute bus spans the + // whole usable sweep instead of clipping against a second clamp. + PFParams::apply(input, 1, ¶ms.speed, + CHROMATIC_VORTEX_SPEED_MIN, CHROMATIC_VORTEX_SPEED_MAX, + CHROMATIC_VORTEX_SPEED_STEP); - params.density += input.knobDeltas[2] * CHROMATIC_VORTEX_DENSITY_STEP; - params.density = constrain(params.density, CHROMATIC_VORTEX_DENSITY_MIN, CHROMATIC_VORTEX_DENSITY_MAX); + PFParams::apply(input, 2, ¶ms.density, + CHROMATIC_VORTEX_DENSITY_MIN, CHROMATIC_VORTEX_DENSITY_MAX, + CHROMATIC_VORTEX_DENSITY_STEP); - params.colorBias += input.knobDeltas[3] * CHROMATIC_VORTEX_COLOR_BIAS_STEP; - params.colorBias = fmodf(params.colorBias, 1.0f); - if (params.colorBias < 0.0f) params.colorBias += 1.0f; + PFParams::applyUnit(input, 3, ¶ms.colorBias, CHROMATIC_VORTEX_COLOR_BIAS_STEP); params.timeAcc += dt * params.speed; } diff --git a/firmware/patternflow/presets/preset_0629_2.h b/firmware/patternflow/presets/preset_0629_2.h index a29a4e7..4c6a80f 100644 --- a/firmware/patternflow/presets/preset_0629_2.h +++ b/firmware/patternflow/presets/preset_0629_2.h @@ -16,11 +16,13 @@ #include "../src/core_canvas.h" #include "../src/core_math.h" #include "../src/core_color.h" +#include "../src/core_params.h" namespace VectorFieldParticleFlowPattern { const char* NAME = "Vector Field Flow"; const char* const KNOB_LABELS[4] = {"Turbulence", "Velocity", "Density", "Palette"}; +constexpr bool ABSOLUTE_READY = true; const float VECTOR_FIELD_TURB_MIN = 0.0f; const float VECTOR_FIELD_TURB_MAX = 1.0f; @@ -69,23 +71,25 @@ void setup() { } void update(float dt, const InputFrame& input) { - // Knob 1: Turbulence (Wrap) - params.turbulence += input.knobDeltas[0] * VECTOR_FIELD_TURB_STEP; - while (params.turbulence < VECTOR_FIELD_TURB_MIN) params.turbulence += (VECTOR_FIELD_TURB_MAX - VECTOR_FIELD_TURB_MIN); - while (params.turbulence > VECTOR_FIELD_TURB_MAX) params.turbulence -= (VECTOR_FIELD_TURB_MAX - VECTOR_FIELD_TURB_MIN); + // Real ranges live in the apply calls so the 0..1000 absolute bus spans + // the whole usable sweep. Turbulence and palette wrap over exactly 0..1, + // which is applyUnit's contract. + + // Knob 1: Turbulence (Wrap 0..1) + PFParams::applyUnit(input, 0, ¶ms.turbulence, VECTOR_FIELD_TURB_STEP); // Knob 2: Speed (Clamp) - params.speed += input.knobDeltas[1] * VECTOR_FIELD_SPEED_STEP; - params.speed = constrain(params.speed, VECTOR_FIELD_SPEED_MIN, VECTOR_FIELD_SPEED_MAX); + PFParams::apply(input, 1, ¶ms.speed, + VECTOR_FIELD_SPEED_MIN, VECTOR_FIELD_SPEED_MAX, + VECTOR_FIELD_SPEED_STEP); // Knob 3: Density (Clamp) - params.density += input.knobDeltas[2] * VECTOR_FIELD_DENSITY_STEP; - params.density = constrain(params.density, VECTOR_FIELD_DENSITY_MIN, VECTOR_FIELD_DENSITY_MAX); + PFParams::apply(input, 2, ¶ms.density, + VECTOR_FIELD_DENSITY_MIN, VECTOR_FIELD_DENSITY_MAX, + VECTOR_FIELD_DENSITY_STEP); - // Knob 4: Palette (Wrap) - params.palette += input.knobDeltas[3] * VECTOR_FIELD_PALETTE_STEP; - while (params.palette < VECTOR_FIELD_PALETTE_MIN) params.palette += (VECTOR_FIELD_PALETTE_MAX - VECTOR_FIELD_PALETTE_MIN); - while (params.palette > VECTOR_FIELD_PALETTE_MAX) params.palette -= (VECTOR_FIELD_PALETTE_MAX - VECTOR_FIELD_PALETTE_MIN); + // Knob 4: Palette (Wrap 0..1) + PFParams::applyUnit(input, 3, ¶ms.palette, VECTOR_FIELD_PALETTE_STEP); params.timeAcc += dt * params.speed; } diff --git a/firmware/patternflow/presets/preset_0701.h b/firmware/patternflow/presets/preset_0701.h index 4c3cca2..1da052e 100644 --- a/firmware/patternflow/presets/preset_0701.h +++ b/firmware/patternflow/presets/preset_0701.h @@ -14,11 +14,13 @@ #include "../src/core_canvas.h" #include "../src/core_math.h" #include "../src/core_color.h" +#include "../src/core_params.h" namespace LissajousWeave { const char* NAME = "Lissajous Weave"; const char* const KNOB_LABELS[4] = {"X Frequency", "Speed", "Y Frequency", "Phase Offset"}; +constexpr bool ABSOLUTE_READY = true; // Knob state tracking initialized to the specified Pattern Lab current values float knob1 = 1.586f; // min 0, max 2.8, step 0.05 @@ -46,24 +48,16 @@ namespace LissajousWeave { void update(float dt, const InputFrame& input) { // Update Knob 1 - knob1 += input.knobDeltas[0] * 0.05f; - if (knob1 < 0.0f) knob1 = 0.0f; - if (knob1 > 2.8f) knob1 = 2.8f; + PFParams::apply(input, 0, &knob1, 0.0f, 2.8f, 0.05f); // Update Knob 2 - knob2 += input.knobDeltas[1] * 0.1f; - if (knob2 < 0.0f) knob2 = 0.0f; - if (knob2 > 2.0f) knob2 = 2.0f; + PFParams::apply(input, 1, &knob2, 0.0f, 2.0f, 0.1f); // Update Knob 3 - knob3 += input.knobDeltas[2] * 0.05f; - if (knob3 < 0.0f) knob3 = 0.0f; - if (knob3 > 7.0f) knob3 = 7.0f; + PFParams::apply(input, 2, &knob3, 0.0f, 7.0f, 0.05f); // Update Knob 4 - knob4 += input.knobDeltas[3] * 0.05f; - if (knob4 < -3.0f) knob4 = -3.0f; - if (knob4 > 3.0f) knob4 = 3.0f; + PFParams::apply(input, 3, &knob4, -3.0f, 3.0f, 0.05f); // Recompute parameters based on updated knob positions freqX = 1.0f + floorf(knob1 * 7.0f); diff --git a/firmware/patternflow/presets/preset_0707.h b/firmware/patternflow/presets/preset_0707.h index 4de5b8e..c4478ec 100644 --- a/firmware/patternflow/presets/preset_0707.h +++ b/firmware/patternflow/presets/preset_0707.h @@ -13,11 +13,13 @@ #include "../src/core_encoders.h" #include "../src/core_canvas.h" #include "../src/core_math.h" +#include "../src/core_params.h" namespace UntitledPattern { const char* NAME = "Untitled pattern"; const char* const KNOB_LABELS[4] = {"Glitch", "Speed", "Freq", "Quantize"}; +constexpr bool ABSOLUTE_READY = true; static const uint8_t RAMP_LUT[256][3] = { {0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0}, @@ -70,26 +72,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - if (input.knobDeltas[0] != 0) { - glitch += input.knobDeltas[0] * UNTITLED_GLITCH_STEP; - if (glitch < 0.0f) glitch = 0.0f; - if (glitch > 2.0f) glitch = 2.0f; - } - if (input.knobDeltas[1] != 0) { - speed += input.knobDeltas[1] * UNTITLED_SPEED_STEP; - if (speed < 0.05f) speed = 0.05f; - if (speed > 2.0f) speed = 2.0f; - } - if (input.knobDeltas[2] != 0) { - freq += input.knobDeltas[2] * UNTITLED_FREQ_STEP; - if (freq < 2.0f) freq = 2.0f; - if (freq > 10.0f) freq = 10.0f; - } - if (input.knobDeltas[3] != 0) { - quantize += input.knobDeltas[3] * UNTITLED_QUANTIZE_STEP; - if (quantize < 2.0f) quantize = 2.0f; - if (quantize > 10.0f) quantize = 10.0f; - } + PFParams::apply(input, 0, &glitch, 0.0f, 2.0f, UNTITLED_GLITCH_STEP); + PFParams::apply(input, 1, &speed, 0.05f, 2.0f, UNTITLED_SPEED_STEP); + PFParams::apply(input, 2, &freq, 2.0f, 10.0f, UNTITLED_FREQ_STEP); + PFParams::apply(input, 3, &quantize, 2.0f, 10.0f, UNTITLED_QUANTIZE_STEP); timeAcc += dt * speed; if (timeAcc > TWO_PI) timeAcc -= TWO_PI; diff --git a/firmware/patternflow/presets/preset_0710.h b/firmware/patternflow/presets/preset_0710.h index 178b9c3..07249e6 100644 --- a/firmware/patternflow/presets/preset_0710.h +++ b/firmware/patternflow/presets/preset_0710.h @@ -13,11 +13,13 @@ #include "../src/core_encoders.h" #include "../src/core_canvas.h" #include "../src/core_math.h" +#include "../src/core_params.h" namespace TileWaves { const char* NAME = "TileWaves"; const char* const KNOB_LABELS[4] = {"Quantize", "Speed", "PhaseShift", "Sharpness"}; +constexpr bool ABSOLUTE_READY = true; static const uint8_t RAMP_LUT[256][3] = { {0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0}, @@ -116,21 +118,13 @@ void setup() { } void update(float dt, const InputFrame& input) { - quantizeState += input.knobDeltas[0] * TILE_QUANTIZE_STEP; - if (quantizeState < TILE_QUANTIZE_MIN) quantizeState = TILE_QUANTIZE_MIN; - if (quantizeState > TILE_QUANTIZE_MAX) quantizeState = TILE_QUANTIZE_MAX; + PFParams::apply(input, 0, &quantizeState, TILE_QUANTIZE_MIN, TILE_QUANTIZE_MAX, TILE_QUANTIZE_STEP); - speedState += input.knobDeltas[1] * TILE_SPEED_STEP; - if (speedState < TILE_SPEED_MIN) speedState = TILE_SPEED_MIN; - if (speedState > TILE_SPEED_MAX) speedState = TILE_SPEED_MAX; + PFParams::apply(input, 1, &speedState, TILE_SPEED_MIN, TILE_SPEED_MAX, TILE_SPEED_STEP); - phaseShiftState += input.knobDeltas[2] * TILE_PHASESHIFT_STEP; - if (phaseShiftState < TILE_PHASESHIFT_MIN) phaseShiftState = TILE_PHASESHIFT_MIN; - if (phaseShiftState > TILE_PHASESHIFT_MAX) phaseShiftState = TILE_PHASESHIFT_MAX; + PFParams::apply(input, 2, &phaseShiftState, TILE_PHASESHIFT_MIN, TILE_PHASESHIFT_MAX, TILE_PHASESHIFT_STEP); - sharpnessState += input.knobDeltas[3] * TILE_SHARPNESS_STEP; - if (sharpnessState < TILE_SHARPNESS_MIN) sharpnessState = TILE_SHARPNESS_MIN; - if (sharpnessState > TILE_SHARPNESS_MAX) sharpnessState = TILE_SHARPNESS_MAX; + PFParams::apply(input, 3, &sharpnessState, TILE_SHARPNESS_MIN, TILE_SHARPNESS_MAX, TILE_SHARPNESS_STEP); timeAcc += dt * speedState; // timeAcc feeds fastSin with integer multiplier (3.0) and floorf for quantization. diff --git a/firmware/patternflow/presets/preset_0712.h b/firmware/patternflow/presets/preset_0712.h index 5f63625..8c1af5e 100644 --- a/firmware/patternflow/presets/preset_0712.h +++ b/firmware/patternflow/presets/preset_0712.h @@ -14,11 +14,13 @@ #include "../src/core_canvas.h" #include "../src/core_math.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace MidsummerSea { const char* NAME = "Midsummer Sea"; const char* const KNOB_LABELS[4] = {"Waves", "Speed", "Sun", "Glitter"}; +constexpr bool ABSOLUTE_READY = true; static const uint8_t RAMP_LUT[256][3] = { {0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0}, @@ -66,21 +68,13 @@ void setup() { } void update(float dt, const InputFrame& input) { - waves += input.knobDeltas[0] * 0.05f; - if (waves < 0.0f) waves = 0.0f; - if (waves > 1.0f) waves = 1.0f; + PFParams::apply(input, 0, &waves, 0.0f, 1.0f, 0.05f); - speed += input.knobDeltas[1] * 0.1f; - if (speed < 0.1f) speed = 0.1f; - if (speed > 3.0f) speed = 3.0f; + PFParams::apply(input, 1, &speed, 0.1f, 3.0f, 0.1f); - sun += input.knobDeltas[2] * 0.05f; - if (sun < 0.0f) sun = 0.0f; - if (sun > 1.0f) sun = 1.0f; + PFParams::apply(input, 2, &sun, 0.0f, 1.0f, 0.05f); - glit += input.knobDeltas[3] * 0.05f; - if (glit < 0.0f) glit = 0.0f; - if (glit > 1.0f) glit = 1.0f; + PFParams::apply(input, 3, &glit, 0.0f, 1.0f, 0.05f); t += dt * speed; // common period for all sine multipliers: 2000π diff --git a/firmware/patternflow/presets/preset_0712_2.h b/firmware/patternflow/presets/preset_0712_2.h index 2049332..85ec459 100644 --- a/firmware/patternflow/presets/preset_0712_2.h +++ b/firmware/patternflow/presets/preset_0712_2.h @@ -14,11 +14,13 @@ #include "../src/core_canvas.h" #include "../src/core_math.h" #include "../src/core_mem.h" +#include "../src/core_params.h" namespace BreakoutArcade { const char* NAME = "Breakout Arcade"; const char* const KNOB_LABELS[4] = {"Paddle", "Speed", "Luck", "Glow"}; +constexpr bool ABSOLUTE_READY = true; static const uint8_t RAMP_LUT[256][3] = { {0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0}, @@ -169,33 +171,25 @@ void update(float dt, const InputFrame& input) { if (!glow) return; // allocation failed — degrade to a blank pattern // --- Knob Paddle (encoder driven, used only for manual mode and idle detection) --- - knobPaddle += input.knobDeltas[0] * 0.05f; - if (knobPaddle < 2.0f) knobPaddle = 2.0f; - if (knobPaddle > 62.0f) knobPaddle = 62.0f; + PFParams::apply(input, 0, &knobPaddle, 2.0f, 62.0f, 0.05f); - speed += input.knobDeltas[1] * 0.1f; - if (speed < 25.0f) speed = 25.0f; - if (speed > 200.0f) speed = 200.0f; + PFParams::apply(input, 1, &speed, 25.0f, 200.0f, 0.1f); - luck += input.knobDeltas[2] * 0.05f; - if (luck < 0.0f) luck = 0.0f; - if (luck > 1.0f) luck = 1.0f; + PFParams::apply(input, 2, &luck, 0.0f, 1.0f, 0.05f); - glowK += input.knobDeltas[3] * 0.05f; - if (glowK < 0.0f) glowK = 0.0f; - if (glowK > 1.0f) glowK = 1.0f; + PFParams::apply(input, 3, &glowK, 0.0f, 1.0f, 0.05f); - if (input.btnPressed[0]) { + if (input.btnPressed[0] && !input.paramAbsoluteActive[0] && !input.knobAudioActive[0]) { memset(dead, 0, sizeof(dead)); } - if (input.btnPressed[1]) { + if (input.btnPressed[1] && !input.paramAbsoluteActive[1] && !input.knobAudioActive[1]) { multiball(); flash = 1.0f; } - if (input.btnPressed[2]) { + if (input.btnPressed[2] && !input.paramAbsoluteActive[2] && !input.knobAudioActive[2]) { wideT = 6.0f; } - if (input.btnPressed[3]) { + if (input.btnPressed[3] && !input.paramAbsoluteActive[3] && !input.knobAudioActive[3]) { fireT = 4.0f; flash = 0.8f; } diff --git a/firmware/patternflow/presets/preset_0713.h b/firmware/patternflow/presets/preset_0713.h index eb24257..82dd197 100644 --- a/firmware/patternflow/presets/preset_0713.h +++ b/firmware/patternflow/presets/preset_0713.h @@ -14,11 +14,13 @@ #include "../src/core_canvas.h" #include "../src/core_math.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace FireflyHollow { const char* NAME = "Firefly Hollow"; const char* const KNOB_LABELS[4] = {"Flies", "Speed", "Glow", "Wind"}; +constexpr bool ABSOLUTE_READY = true; // Ramp LUT – DO NOT EDIT (generated from user ramp, 256 entries) static const uint8_t RAMP_LUT[256][3] = { @@ -120,21 +122,13 @@ void setup() { void update(float dt, const InputFrame& input) { // ----- knobs ----- - knobFlies += input.knobDeltas[0] * 0.05f; - if (knobFlies < 2.0f) knobFlies = 2.0f; - if (knobFlies > 34.0f) knobFlies = 34.0f; + PFParams::apply(input, 0, &knobFlies, 2.0f, 34.0f, 0.05f); - knobSpeed += input.knobDeltas[1] * 0.1f; - if (knobSpeed < 0.2f) knobSpeed = 0.2f; - if (knobSpeed > 5.0f) knobSpeed = 5.0f; + PFParams::apply(input, 1, &knobSpeed, 0.2f, 5.0f, 0.1f); - knobGlow += input.knobDeltas[2] * 0.05f; - if (knobGlow < 0.0f) knobGlow = 0.0f; - if (knobGlow > 2.0f) knobGlow = 2.0f; + PFParams::apply(input, 2, &knobGlow, 0.0f, 2.0f, 0.05f); - knobWind += input.knobDeltas[3] * 0.05f; - if (knobWind < 0.0f) knobWind = 0.0f; - if (knobWind > 2.0f) knobWind = 2.0f; + PFParams::apply(input, 3, &knobWind, 0.0f, 2.0f, 0.05f); int flies = (int)lroundf(knobFlies); if (flies < 2) flies = 2; diff --git a/firmware/patternflow/presets/preset_0715.h b/firmware/patternflow/presets/preset_0715.h index 015cc6b..421eecc 100644 --- a/firmware/patternflow/presets/preset_0715.h +++ b/firmware/patternflow/presets/preset_0715.h @@ -13,11 +13,13 @@ #include "../src/core_encoders.h" #include "../src/core_canvas.h" #include "../src/core_math.h" +#include "../src/core_params.h" namespace PoincareSphere { const char* NAME = "Poincaré Sphere"; const char* const KNOB_LABELS[4] = {"Color Tone", "Speed", "Grid Density", "Curvature"}; +constexpr bool ABSOLUTE_READY = true; static float hueParam = 1.0f; // Knob 1, range 0..1 static float speed = 8.62f; // Knob 2, range 0.1..10 @@ -31,20 +33,16 @@ void setup() { void update(float dt, const InputFrame& input) { // Knob 1: color tone - hueParam += input.knobDeltas[0] * 0.05f; - hueParam = fmaxf(0.0f, fminf(1.0f, hueParam)); + PFParams::apply(input, 0, &hueParam, 0.0f, 1.0f, 0.05f); // Knob 2: rotation speed - speed += input.knobDeltas[1] * 0.1f; - speed = fmaxf(0.1f, fminf(10.0f, speed)); + PFParams::apply(input, 1, &speed, 0.1f, 10.0f, 0.1f); // Knob 3: grid density - density += input.knobDeltas[2] * 0.05f; - density = fmaxf(0.0f, fminf(5.0f, density)); + PFParams::apply(input, 2, &density, 0.0f, 5.0f, 0.05f); // Knob 4: projection curvature - curve += input.knobDeltas[3] * 0.05f; - curve = fmaxf(0.0f, fminf(5.0f, curve)); + PFParams::apply(input, 3, &curve, 0.0f, 5.0f, 0.05f); t += dt * speed; // Wrap at common period 20π to avoid float precision loss over days. diff --git a/firmware/patternflow/presets/preset_0716.h b/firmware/patternflow/presets/preset_0716.h index 6f45766..8204069 100644 --- a/firmware/patternflow/presets/preset_0716.h +++ b/firmware/patternflow/presets/preset_0716.h @@ -14,11 +14,13 @@ #include "../src/core_canvas.h" #include "../src/core_math.h" #include "../src/core_color.h" +#include "../src/core_params.h" namespace TriMarch { const char* NAME = "Tri March"; const char* const KNOB_LABELS[4] = {"Triangle size", "Speed", "March angle", "Color palette"}; +constexpr bool ABSOLUTE_READY = true; static float tsize = 0.0f; static float speed = 3.931f; @@ -31,21 +33,13 @@ void setup() { } void update(float dt, const InputFrame& input) { - tsize += input.knobDeltas[0] * 0.05f; - if (tsize < 0.0f) tsize = 0.0f; - if (tsize > 1.0f) tsize = 1.0f; + PFParams::apply(input, 0, &tsize, 0.0f, 1.0f, 0.05f); - speed += input.knobDeltas[1] * 0.1f; - if (speed < -3.0f) speed = -3.0f; - if (speed > 4.0f) speed = 4.0f; + PFParams::apply(input, 1, &speed, -3.0f, 4.0f, 0.1f); - angle += input.knobDeltas[2] * 0.05f; - if (angle < -0.001f) angle = -0.001f; - if (angle > 5.0f) angle = 5.0f; + PFParams::apply(input, 2, &angle, -0.001f, 5.0f, 0.05f); - palette += input.knobDeltas[3] * 0.05f; - if (palette < 0.0f) palette = 0.0f; - if (palette > 4.0f) palette = 4.0f; + PFParams::apply(input, 3, &palette, 0.0f, 4.0f, 0.05f); t += dt * speed; // Period needed for multipliers 10, 2, 0.05 = 40π = 20*TWO_PI diff --git a/firmware/patternflow/presets/preset_0718.h b/firmware/patternflow/presets/preset_0718.h index 4c81a53..6ed4940 100644 --- a/firmware/patternflow/presets/preset_0718.h +++ b/firmware/patternflow/presets/preset_0718.h @@ -13,11 +13,13 @@ #include "../src/core_canvas.h" #include "../src/core_math.h" #include "../src/core_color.h" +#include "../src/core_params.h" namespace WarpedWave { const char* NAME = "Warped Wave"; const char* const KNOB_LABELS[4] = {"Hue", "Speed", "Warp Amp", "Warp Freq"}; +constexpr bool ABSOLUTE_READY = true; static float s_hue = 0.0f; static float s_speed = 2.0f; @@ -31,23 +33,18 @@ void setup() { } void update(float dt, const InputFrame& input) { - // Accumulate knob deltas - s_hue += input.knobDeltas[0] * 0.05f; - s_speed += input.knobDeltas[1] * 0.1f; - s_warpAmp += input.knobDeltas[2] * 0.05f; - s_warpFreq += input.knobDeltas[3] * 0.05f; - - // Clamp to documented ranges - s_hue = fmaxf(0.0f, fminf(1.0f, s_hue)); - s_speed = fmaxf(-20.0f, fminf(20.0f, s_speed)); - s_warpAmp = fmaxf(0.1f, fminf(1.0f, s_warpAmp)); - s_warpFreq = fmaxf(0.0f, fminf(30.0f, s_warpFreq)); + // Documented ranges in the apply calls so the 0..1000 absolute bus spans + // the whole usable sweep (clamping included — no second clamp needed). + PFParams::apply(input, 0, &s_hue, 0.0f, 1.0f, 0.05f); + PFParams::apply(input, 1, &s_speed, -20.0f, 20.0f, 0.1f); + PFParams::apply(input, 2, &s_warpAmp, 0.1f, 1.0f, 0.05f); + PFParams::apply(input, 3, &s_warpFreq, 0.0f, 30.0f, 0.05f); // Button resets (press, not long-press) - if (input.btnPressed[0]) s_hue = 0.0f; - if (input.btnPressed[1]) s_speed = 2.0f; - if (input.btnPressed[2]) s_warpAmp = 0.2f; - if (input.btnPressed[3]) s_warpFreq = 0.5f; + if (input.btnPressed[0] && !input.paramAbsoluteActive[0] && !input.knobAudioActive[0]) s_hue = 0.0f; + if (input.btnPressed[1] && !input.paramAbsoluteActive[1] && !input.knobAudioActive[1]) s_speed = 2.0f; + if (input.btnPressed[2] && !input.paramAbsoluteActive[2] && !input.knobAudioActive[2]) s_warpAmp = 0.2f; + if (input.btnPressed[3] && !input.paramAbsoluteActive[3] && !input.knobAudioActive[3]) s_warpFreq = 0.5f; // Map warpAmp from raw 0.1..1 to 0..1.225 (as in JS: v[2]*0.25) // We store raw and convert on use, preserving the JS mapping. diff --git a/firmware/patternflow/presets/preset_0719.h b/firmware/patternflow/presets/preset_0719.h index 27b9e44..4d7a463 100644 --- a/firmware/patternflow/presets/preset_0719.h +++ b/firmware/patternflow/presets/preset_0719.h @@ -13,6 +13,7 @@ #include "../src/core_encoders.h" #include "../src/core_canvas.h" #include "../src/core_mem.h" +#include "../src/core_params.h" namespace MagVortex { @@ -23,6 +24,7 @@ const char* const KNOB_LABELS[4] = { "SpinPull", "InflowPitch" }; +constexpr bool ABSOLUTE_READY = true; // Parameter state static float trailDecay = 0.924f; @@ -124,30 +126,22 @@ void update(float dt, const InputFrame& input) { if (!densityMap) return; // allocation failed — degrade to a blank pattern // Knob 0: TrailDecay - trailDecay += input.knobDeltas[0] * 0.05f; - if (trailDecay < 0.5f) trailDecay = 0.5f; - if (trailDecay > 0.98f) trailDecay = 0.98f; + PFParams::apply(input, 0, &trailDecay, 0.5f, 0.98f, 0.05f); // Knob 1: Velocity - velocity += input.knobDeltas[1] * 0.1f; - if (velocity < -3.0f) velocity = -3.0f; - if (velocity > 5.0f) velocity = 5.0f; + PFParams::apply(input, 1, &velocity, -3.0f, 5.0f, 0.1f); // Knob 2: SpinPull - spin += input.knobDeltas[2] * 0.05f; - if (spin < 0.0f) spin = 0.0f; - if (spin > 3.0f) spin = 3.0f; + PFParams::apply(input, 2, &spin, 0.0f, 3.0f, 0.05f); // Knob 3: InflowPitch - pitch += input.knobDeltas[3] * 0.05f; - if (pitch < -20.0f) pitch = -20.0f; - if (pitch > 20.0f) pitch = 20.0f; + PFParams::apply(input, 3, &pitch, -20.0f, 20.0f, 0.05f); // Button resets - if (input.btnPressed[0]) trailDecay = 0.924f; - if (input.btnPressed[1]) velocity = 2.013f; - if (input.btnPressed[2]) spin = 0.746f; - if (input.btnPressed[3]) pitch = -2.203f; + if (input.btnPressed[0] && !input.paramAbsoluteActive[0] && !input.knobAudioActive[0]) trailDecay = 0.924f; + if (input.btnPressed[1] && !input.paramAbsoluteActive[1] && !input.knobAudioActive[1]) velocity = 2.013f; + if (input.btnPressed[2] && !input.paramAbsoluteActive[2] && !input.knobAudioActive[2]) spin = 0.746f; + if (input.btnPressed[3] && !input.paramAbsoluteActive[3] && !input.knobAudioActive[3]) pitch = -2.203f; // Decay the density map for (int i = 0; i < PANEL_RES_W * PANEL_RES_H; i++) { diff --git a/firmware/patternflow/presets/preset_a_big_hit.h b/firmware/patternflow/presets/preset_a_big_hit.h index 826e1ec..c4749f0 100644 --- a/firmware/patternflow/presets/preset_a_big_hit.h +++ b/firmware/patternflow/presets/preset_a_big_hit.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace PatternABigHit { const char* NAME = "a big hit"; const char* const KNOB_LABELS[4] = {"hueBase", "speed", "scale", "chaos"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float hueBase = 0.0f; @@ -49,11 +51,11 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.hueBase = fmodf((float)((params.hueBase + input.knobDeltas[0] * 0.05f)), (float)(1.0f)); + PFParams::applyUnit(input, 0, ¶ms.hueBase, 0.05f); if (params.hueBase < 0) params.hueBase += 1.0f; - params.speed = max(0.0f, params.speed + input.knobDeltas[1] * 0.05f); - params.scale = constrain(params.scale + input.knobDeltas[2] * 0.01f, 0.02f, 0.2f); - params.chaos = constrain(params.chaos + input.knobDeltas[3] * 0.1f, 0.0f, 3.0f); + PFParams::apply(input, 1, ¶ms.speed, 0.0f, 10.0f, 0.05f); + PFParams::apply(input, 2, ¶ms.scale, 0.02f, 0.2f, 0.01f); + PFParams::apply(input, 3, ¶ms.chaos, 0.0f, 3.0f, 0.1f); params.timeAcc += dt * params.speed; } diff --git a/firmware/patternflow/presets/preset_origin.h b/firmware/patternflow/presets/preset_origin.h index 9b18e6b..60aea4e 100644 --- a/firmware/patternflow/presets/preset_origin.h +++ b/firmware/patternflow/presets/preset_origin.h @@ -11,10 +11,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_canvas.h" +#include "../src/core_params.h" namespace Origin { const char* NAME = "Origin"; const char* KNOB_LABELS[4] = {"hue", "speed", "mode", "freq"}; + constexpr bool ABSOLUTE_READY = true; // --- 상태 변수 --- struct Params { @@ -91,57 +93,29 @@ namespace Origin { } void update(float dt, const InputFrame& input) { - // Each knob: audio override (when active) takes priority. Audio - // value is 0..1 normalized — map to the same parameter range the - // encoder integrates toward, so toggling between encoder and audio - // doesn't jump. - - // Enc1: Hue (0..360°) - if (input.knobAudioActive[0]) { - params.hueDeg = (int)(input.knobAudioValue[0] * 360.0f) % 360; - } else { - int d0 = input.knobDeltas[0]; - if (d0 != 0) { - params.hueDeg = (params.hueDeg + (int)(d0 * 10)) % 360; - if (params.hueDeg < 0) params.hueDeg += 360; - } - if (input.btnPressed[0]) { params.hueDeg = 0; Serial.println("[Origin] Hue → 0°"); } + // Absolute MQTT (0..1000) > Weather/audio 0..1 > encoder/OSC deltas. + PFParams::applyInt(input, 0, ¶ms.hueDeg, 0, 359, 10, true); + if (input.btnPressed[0] && !input.paramAbsoluteActive[0] && !input.knobAudioActive[0]) { + params.hueDeg = 0; + Serial.println("[Origin] Hue → 0°"); } - // Enc2: Speed (0..5) - if (input.knobAudioActive[1]) { - params.speed = input.knobAudioValue[1] * 5.0f; - } else { - int d1 = input.knobDeltas[1]; - if (d1 != 0) { - params.speed = constrain(params.speed + d1 * 0.1f, 0.0f, 5.0f); - } - if (input.btnPressed[1]) { params.speed = 0.0f; Serial.println("[Origin] Speed → 0"); } + PFParams::apply(input, 1, ¶ms.speed, 0.0f, 5.0f, 0.1f); + if (input.btnPressed[1] && !input.paramAbsoluteActive[1] && !input.knobAudioActive[1]) { + params.speed = 0.0f; + Serial.println("[Origin] Speed → 0"); } - // Enc3: Mode (preset index, 0..NUM_PRESETS-1). Audio mapped by - // dividing the 0..1 range into NUM_PRESETS buckets. - if (input.knobAudioActive[2]) { - int idx = (int)(input.knobAudioValue[2] * (float)NUM_PRESETS); - if (idx >= NUM_PRESETS) idx = NUM_PRESETS - 1; - params.mode = idx; - } else { - int d2 = input.knobDeltas[2]; - if (d2 != 0) { - params.mode = ((params.mode + (int)d2) % NUM_PRESETS + NUM_PRESETS) % NUM_PRESETS; - } - if (input.btnPressed[2]) { params.mode = 0; Serial.println("[Origin] Mode → 0"); } + PFParams::applyIndex(input, 2, ¶ms.mode, NUM_PRESETS); + if (input.btnPressed[2] && !input.paramAbsoluteActive[2] && !input.knobAudioActive[2]) { + params.mode = 0; + Serial.println("[Origin] Mode → 0"); } - // Enc4: Freq (0.1..1000) - if (input.knobAudioActive[3]) { - params.freq = 0.1f + input.knobAudioValue[3] * (1000.0f - 0.1f); - } else { - int d3 = input.knobDeltas[3]; - if (d3 != 0) { - params.freq = constrain(params.freq + (d3 * 10.0f), 0.1f, 1000.0f); - } - if (input.btnPressed[3]) { params.freq = 0.1f; Serial.println("[Origin] Freq → 0.1"); } + PFParams::apply(input, 3, ¶ms.freq, 0.1f, 1000.0f, 10.0f); + if (input.btnPressed[3] && !input.paramAbsoluteActive[3] && !input.knobAudioActive[3]) { + params.freq = 0.1f; + Serial.println("[Origin] Freq → 0.1"); } // Apply Mode Change diff --git a/firmware/patternflow/presets/preset_wave_saw.h b/firmware/patternflow/presets/preset_wave_saw.h index 2523609..06eb688 100644 --- a/firmware/patternflow/presets/preset_wave_saw.h +++ b/firmware/patternflow/presets/preset_wave_saw.h @@ -11,10 +11,12 @@ #include "../src/core_math.h" #include "../src/core_noise.h" #include "../src/core_canvas.h" +#include "../src/core_params.h" namespace WaveSaw { const char* NAME = "Wave Saw"; const char* KNOB_LABELS[4] = {"angle", "scale", "dist", "dscale"}; +constexpr bool ABSOLUTE_READY = true; // --- Wave / 매핑 상수 --- const float DETAIL_ROUGHNESS = 0.22f; @@ -51,35 +53,17 @@ namespace WaveSaw { } void update(float dt, const InputFrame& input) { - // Enc1: Angle (방향) - int d0 = input.knobDeltas[0]; - if (d0 != 0) { - angle += d0 * 0.1f; // 회전 속도 조절 - if (angle > PI * 2) angle -= PI * 2; - if (angle < 0) angle += PI * 2; - } - if (input.btnPressed[0]) { angle = 0.0f; Serial.println("[WaveSaw] Angle -> 0"); } + PFParams::apply(input, 0, &angle, 0.0f, (float)(PI * 2), 0.1f); + if (input.btnPressed[0] && !input.paramAbsoluteActive[0] && !input.knobAudioActive[0]) { angle = 0.0f; Serial.println("[WaveSaw] Angle -> 0"); } - // Enc2: Scale (밀도) - int d1 = input.knobDeltas[1]; - if (d1 != 0) { - scale = constrain(scale + d1 * 0.2f, SCALE_MIN, SCALE_MAX); - } - if (input.btnPressed[1]) { scale = 3.0f; Serial.println("[WaveSaw] Scale -> 3.0"); } + PFParams::apply(input, 1, &scale, SCALE_MIN, SCALE_MAX, 0.2f); + if (input.btnPressed[1] && !input.paramAbsoluteActive[1] && !input.knobAudioActive[1]) { scale = 3.0f; Serial.println("[WaveSaw] Scale -> 3.0"); } - // Enc3: Distortion (왜곡 정도) - int d2 = input.knobDeltas[2]; - if (d2 != 0) { - dist = constrain(dist + d2 * 0.1f, DIST_MIN, DIST_MAX); - } - if (input.btnPressed[2]) { dist = 0.0f; Serial.println("[WaveSaw] Dist -> 0.0"); } + PFParams::apply(input, 2, &dist, DIST_MIN, DIST_MAX, 0.1f); + if (input.btnPressed[2] && !input.paramAbsoluteActive[2] && !input.knobAudioActive[2]) { dist = 0.0f; Serial.println("[WaveSaw] Dist -> 0.0"); } - // Enc4: Detail Scale (노이즈 디테일 크기) - int d3 = input.knobDeltas[3]; - if (d3 != 0) { - dScale = constrain(dScale + d3 * 0.2f, DSCALE_MIN, DSCALE_MAX); - } - if (input.btnPressed[3]) { dScale = 1.0f; Serial.println("[WaveSaw] DScale -> 1.0"); } + PFParams::apply(input, 3, &dScale, DSCALE_MIN, DSCALE_MAX, 0.2f); + if (input.btnPressed[3] && !input.paramAbsoluteActive[3] && !input.knobAudioActive[3]) { dScale = 1.0f; Serial.println("[WaveSaw] DScale -> 1.0"); } // 애니메이션 페이즈 업데이트 phase += dt * PHASE_PER_SEC; diff --git a/firmware/patternflow/src/core_encoders.h b/firmware/patternflow/src/core_encoders.h index b794feb..6060101 100644 --- a/firmware/patternflow/src/core_encoders.h +++ b/firmware/patternflow/src/core_encoders.h @@ -131,6 +131,14 @@ struct InputFrame { // need audio-specific code. bool knobAudioActive[4]; float knobAudioValue[4]; + + // Absolute Director / Show-manager bus (MQTT param/1..4). Wire scale + // is integer 0..1000. When active, patterns that use PFParams::apply + // set mapped params from this instead of integrating deltas. + // Appended so older modules that only read the fields above still work + // — this struct is mirrored byte-for-byte by abi/pf_abi.h::PFInputFrame. + bool paramAbsoluteActive[4]; + uint16_t paramAbsolute[4]; }; Button btn1, btn2, btn3, btn4; diff --git a/firmware/patternflow/src/core_module_loader.h b/firmware/patternflow/src/core_module_loader.h index 8cfaebc..c67593b 100644 --- a/firmware/patternflow/src/core_module_loader.h +++ b/firmware/patternflow/src/core_module_loader.h @@ -689,7 +689,11 @@ inline bool load(fs::FS& filesystem, const char* path) { using Entry = const PFPatternModule* (*)(const PFHostAPI*); Entry entry = reinterpret_cast(entryAddress); active = entry(&hostAPI); - if (!active || active->abi_version != PF_ABI_VERSION || + // Descriptor version 1 (pre-absolute) and 2 (reads the appended + // absolute-param InputFrame fields) both run here — the host always fills + // the extended frame. Anything else is a layout we do not provide. + if (!active || active->abi_version < PF_ABI_VERSION || + active->abi_version > PF_ABI_MODULE_VERSION || active->panel_w != PANEL_RES_W || active->panel_h != PANEL_RES_H || !active->name || !active->knob_labels || !active->setup || !active->update || !active->draw) { diff --git a/firmware/patternflow/src/core_mqtt.h b/firmware/patternflow/src/core_mqtt.h index bc54e63..d68c69a 100644 --- a/firmware/patternflow/src/core_mqtt.h +++ b/firmware/patternflow/src/core_mqtt.h @@ -1,39 +1,32 @@ // ═══════════════════════════════════════════════════════════ -// PatternFlow - MQTT sidechannel (knobs + pattern name over a broker) +// PatternFlow - MQTT sidechannel (knobs + pattern + absolute params) // -// The same shape as core_osc.h, pointed at brokers instead of DAWs. Where -// OSC talks to Ableton over UDP in device-local terms (indices, deltas), -// this publishes plain retained values on plain topics, so the other end -// can be a second panel, Home Assistant, or a shell one-liner: +// Topics per prefix (no wildcards — ACL-friendly exact list): // -// /knob/1..4 absolute click count, retained -// /pattern display name, retained +// /knob/1..4 absolute click counts (live; non-retain) +// /pattern display name (live; non-retain) +// /message banner text (broadcast: retain; channels: live) +// /param/1..4 absolute 0..1000 (live; non-retain) +// /snapshot compact JSON mid-join state (channels 1–5 only; retain) +// Director-only (local broker, not the public ACL): +// /query panel → inventory +// /select Director → mark modules on /patterns for ZIP export +// /select/ack panel → {matched,presets,missing,rev} // -// Retained is the point. A Subscriber that joins late still gets the -// current state — hence "turn on the second panel and it catches up" -// rather than "wait for the first knob to move". +// Retention policy (Performance Director proposal): +// Broadcast message retain +// Channel/Live snapshot retain +// Everything else non-retain // -// Roles, chosen on /mqtt and kept in NVS: -// Off socket closed, nothing published -// Publisher sends knob clicks + pattern name as they change -// Subscriber applies the retained snapshot, then follows live changes +// Roles on /mqtt: +// Off socket closed +// Publisher sends knobs + pattern (+ Live snapshot heartbeat) +// Subscriber applies snapshot (on channels) then follows live topics // -// Inbound values are absolute, but patterns only understand deltas, so -// applyRemoteKnob() differences against the previous value and queues the -// result. consumeKnobDelta() then reads exactly like the OSC one, which is -// why the sketch merges both with adjacent lines. -// -// Compiled in by default, but inert until a role is picked AND a broker -// host is configured — see the MQTT block in net_config.h. -// -// Include AFTER pattern_registry.h: name lookup walks patterns[], the same -// arrangement core_patterns_http.h uses. -// -// Originally written by Simone Majocchi (@SimonePDA) for his Patternflow -// fork and contributed upstream. Adapted here to this tree's pattern -// registry and given a non-blocking connect path; the protocol design, -// the role model, and the topic layout are his. +// Channel presets set the prefix: patternflow / patternflow1..4 / patternflow5. +// Channels 1–4 force Subscriber; Broadcast and Live allow Pub or Sub. // +// Include AFTER pattern_registry.h. // License: MIT // ═══════════════════════════════════════════════════════════ #pragma once @@ -46,6 +39,7 @@ #include #include #include "pubsubclient/PubSubClient.h" +#include "core_pack_select.h" #endif namespace PatternflowMqtt { @@ -56,36 +50,45 @@ enum Role : uint8_t { ROLE_SUBSCRIBER = 2 }; +// UI / NVS channel preset. Off is role-only; Custom means freeform prefix. +enum Channel : uint8_t { + CHANNEL_OFF = 0, + CHANNEL_BROADCAST = 1, + CHANNEL_1 = 2, + CHANNEL_2 = 3, + CHANNEL_3 = 4, + CHANNEL_4 = 5, + CHANNEL_LIVE = 6, + CHANNEL_CUSTOM = 7 +}; + +// Normal = saved community/HA broker. Director = local anonymous overlay +// (host = PC LAN IP, port 1883, prefix patternflow, Subscriber). Normal +// credentials stay in NVS and come back when leaving Director mode. +enum MqttMode : uint8_t { + MQTT_MODE_NORMAL = 0, + MQTT_MODE_DIRECTOR = 1 +}; + #if PF_MQTT_ENABLED -// Long enough for any preset name or module slug; nothing in the registry -// is allowed to be longer on the wire. constexpr size_t NAME_BYTES = 48; +constexpr size_t MESSAGE_BYTES = 80; +constexpr size_t SNAPSHOT_BYTES = 384; -// Everything below exists because this runs in the single loop that also -// renders the panel and answers the web console. A broker that stops -// answering must cost that loop a bounded, occasional pause — never a -// multi-second stall every few seconds, which reads from outside as "the -// device is up (it pings, it accepts TCP) but the console is dead". -// -// Three things could block, and all three are now capped: -// DNS resolved once and cached, not on every attempt -// TCP setConnectionTimeout below -// handshake setSocketTimeout below constexpr uint32_t CONNECT_TIMEOUT_MS = 1500; constexpr uint32_t SOCKET_TIMEOUT_S = 1; -// Retry pacing. A broker that is simply gone should be retried rarely. constexpr uint32_t RECONNECT_MIN_MS = 5000; constexpr uint32_t RECONNECT_MID_MS = 15000; constexpr uint32_t RECONNECT_MAX_MS = 60000; -// After this many consecutive failures the cached address is dropped, in -// case the broker moved rather than went away. constexpr uint8_t RERESOLVE_AFTER = 4; +constexpr uint32_t SNAPSHOT_HEARTBEAT_MS = 8000; inline WiFiClient net; inline PubSubClient client(net); inline bool started = false; inline Role role = ROLE_OFF; +inline Channel channel = CHANNEL_BROADCAST; inline uint32_t lastReconnectMs = 0; inline char clientId[20] = {}; inline char lastError[80] = {}; @@ -96,20 +99,21 @@ inline bool haveKnob[4] = {false, false, false, false}; inline long lastRemoteKnob[4] = {0, 0, 0, 0}; inline int32_t pendingKnobDelta[4] = {0, 0, 0, 0}; inline int pendingPatternIdx = -1; +inline char overlayText[MESSAGE_BYTES] = {}; +inline uint32_t overlayExpiresMs = 0; +inline bool overlaySticky = false; inline IPAddress brokerIp; inline bool brokerResolved = false; inline uint8_t failures = 0; -// ── Broker settings, entered on /mqtt and kept in NVS ──────────────────── -// -// Typed in rather than compiled in, for the same reason Wi-Fi is: a broker -// is per-owner, and the alternative is either everybody sharing one set of -// credentials in the public source or nobody having a broker at all. The -// PF_MQTT_* macros remain as build-time defaults for people who do want to -// bake their own in; anything saved here wins over them. -// -// Sizes are deliberate — this is internal DRAM, which is the scarce thing on -// this board (see the measurements in net_config.h). 176 bytes total. +// Absolute param bus (held until physical encoder motion releases a channel). +inline bool paramHeld[4] = {false, false, false, false}; +inline uint16_t paramValue[4] = {500, 500, 500, 500}; +inline uint32_t paramHeldAtMs[4] = {0, 0, 0, 0}; +inline uint32_t lastSnapshotPubMs = 0; +// Ignore encoder chatter briefly after an absolute set (Director spam / noise). +constexpr uint32_t ABSOLUTE_RELEASE_GRACE_MS = 250; + constexpr size_t HOST_BYTES = 64; constexpr size_t USER_BYTES = 32; constexpr size_t PASS_BYTES = 48; @@ -120,30 +124,27 @@ inline char cfgUser[USER_BYTES] = PF_MQTT_USER; inline char cfgPass[PASS_BYTES] = PF_MQTT_PASS; inline char cfgPrefix[PREFIX_BYTES] = PF_MQTT_PREFIX; inline uint16_t cfgPort = PF_MQTT_PORT; +inline MqttMode mqttMode = MQTT_MODE_NORMAL; +inline char dirHost[HOST_BYTES] = {}; +// -2 idle, -1 header, 0..n-1 items, n end +inline int inventorySendIdx = -2; -// ── Message banner (@SimonePDA) ────────────────────────────────────────── -// -// Anything published to /message is drawn over the running pattern -// for a few seconds. Receive-only: a panel never publishes here, so the -// sender is Home Assistant, a script, or somebody with MQTT Explorer open. -// -// Deliberately a broadcast — every panel on the prefix shows it. There is no -// per-device topic on purpose: addressing one panel would need a wildcard in -// the broker's ACL, and a fixed topic list is what keeps a shared broker -// from becoming somewhere anyone can invent topics. -// -// Shown per receipt rather than held. A retained payload — which is how you -// would leave a note for a panel that is currently off — otherwise comes -// back on every reconnect and every reboot, and a banner you cannot clear by -// power-cycling is a fault, not a feature. -constexpr size_t MESSAGE_BYTES = 80; -inline char overlayText[MESSAGE_BYTES] = {}; -inline uint32_t overlayUntilMs = 0; +inline bool isDirectorMode() { return mqttMode == MQTT_MODE_DIRECTOR; } -inline bool hasBroker() { return cfgHost[0] != '\0'; } +inline const char* livePrefix() { + return isDirectorMode() ? "patternflow" : cfgPrefix; +} + +inline const char* connectHost() { + return isDirectorMode() ? dirHost : cfgHost; +} + +inline uint16_t connectPort() { + return isDirectorMode() ? (uint16_t)1883 : cfgPort; +} + +inline bool hasBroker() { return connectHost()[0] != '\0'; } -// Back off as failures pile up: a broker that is down stays down, and each -// attempt costs the loop up to CONNECT_TIMEOUT_MS. inline uint32_t reconnectDelayMs() { if (failures == 0) return RECONNECT_MIN_MS; if (failures < RERESOLVE_AFTER) return RECONNECT_MID_MS; @@ -162,9 +163,81 @@ inline const char* roleName(Role r) { } } +inline const char* channelName(Channel c) { + switch (c) { + case CHANNEL_OFF: return "off"; + case CHANNEL_BROADCAST: return "broadcast"; + case CHANNEL_1: return "ch1"; + case CHANNEL_2: return "ch2"; + case CHANNEL_3: return "ch3"; + case CHANNEL_4: return "ch4"; + case CHANNEL_LIVE: return "live"; + case CHANNEL_CUSTOM: return "custom"; + default: return "custom"; + } +} + +inline Channel channelFromName(const char* name) { + if (!name || !name[0]) return CHANNEL_CUSTOM; + if (strcasecmp(name, "off") == 0 || strcasecmp(name, "none") == 0) return CHANNEL_OFF; + if (strcasecmp(name, "broadcast") == 0 || strcasecmp(name, "bc") == 0) return CHANNEL_BROADCAST; + if (strcasecmp(name, "ch1") == 0 || strcmp(name, "1") == 0) return CHANNEL_1; + if (strcasecmp(name, "ch2") == 0 || strcmp(name, "2") == 0) return CHANNEL_2; + if (strcasecmp(name, "ch3") == 0 || strcmp(name, "3") == 0) return CHANNEL_3; + if (strcasecmp(name, "ch4") == 0 || strcmp(name, "4") == 0) return CHANNEL_4; + if (strcasecmp(name, "live") == 0 || strcasecmp(name, "ch5") == 0 || strcmp(name, "5") == 0) { + return CHANNEL_LIVE; + } + if (strcasecmp(name, "custom") == 0) return CHANNEL_CUSTOM; + return CHANNEL_CUSTOM; +} + +inline const char* prefixForChannel(Channel c) { + switch (c) { + case CHANNEL_BROADCAST: return "patternflow"; + case CHANNEL_1: return "patternflow1"; + case CHANNEL_2: return "patternflow2"; + case CHANNEL_3: return "patternflow3"; + case CHANNEL_4: return "patternflow4"; + case CHANNEL_LIVE: return "patternflow5"; + default: return nullptr; + } +} + +inline Channel channelFromPrefix(const char* prefix) { + if (!prefix || !prefix[0]) return CHANNEL_CUSTOM; + if (strcmp(prefix, "patternflow") == 0) return CHANNEL_BROADCAST; + if (strcmp(prefix, "patternflow1") == 0) return CHANNEL_1; + if (strcmp(prefix, "patternflow2") == 0) return CHANNEL_2; + if (strcmp(prefix, "patternflow3") == 0) return CHANNEL_3; + if (strcmp(prefix, "patternflow4") == 0) return CHANNEL_4; + if (strcmp(prefix, "patternflow5") == 0) return CHANNEL_LIVE; + return CHANNEL_CUSTOM; +} + +inline bool channelForcesSubscriber(Channel c) { + return c >= CHANNEL_1 && c <= CHANNEL_4; +} + +inline bool isBroadcastPrefix(const char* prefix) { + return prefix && strcmp(prefix, "patternflow") == 0; +} + +// patternflow1..5 — timed shows + Live (snapshot retain applies). +inline bool isChannelShowPrefix(const char* prefix) { + if (!prefix) return false; + if (strncmp(prefix, "patternflow", 11) != 0) return false; + char digit = prefix[11]; + return digit >= '1' && digit <= '5' && prefix[12] == '\0'; +} + +inline bool retainMessageTopic() { return isBroadcastPrefix(livePrefix()); } +inline bool retainSnapshotTopic() { return isChannelShowPrefix(livePrefix()); } +inline bool retainLeafTopic() { return false; } + inline const char* stateText() { if (!hasBroker()) return "no broker configured"; - if (role == ROLE_OFF) return "off"; + if (role == ROLE_OFF || channel == CHANNEL_OFF) return "off"; if (WiFi.status() != WL_CONNECTED) return "wifi down"; if (client.connected()) return "connected"; switch (client.state()) { @@ -182,15 +255,53 @@ inline const char* stateText() { } inline void topicKnob(char* buf, size_t n, int index) { - snprintf(buf, n, "%s/knob/%d", cfgPrefix, index + 1); + snprintf(buf, n, "%s/knob/%d", livePrefix(), index + 1); } inline void topicPattern(char* buf, size_t n) { - snprintf(buf, n, "%s/pattern", cfgPrefix); + snprintf(buf, n, "%s/pattern", livePrefix()); } inline void topicMessage(char* buf, size_t n) { - snprintf(buf, n, "%s/message", cfgPrefix); + snprintf(buf, n, "%s/message", livePrefix()); +} + +inline void topicParam(char* buf, size_t n, int index) { + snprintf(buf, n, "%s/param/%d", livePrefix(), index + 1); +} + +inline void topicSnapshot(char* buf, size_t n) { + snprintf(buf, n, "%s/snapshot", livePrefix()); +} + +inline void topicQuery(char* buf, size_t n) { + snprintf(buf, n, "%s/query", livePrefix()); +} + +inline void topicInventory(char* buf, size_t n) { + snprintf(buf, n, "%s/inventory", livePrefix()); +} + +inline void topicInventoryItem(char* buf, size_t n, int index) { + snprintf(buf, n, "%s/inventory/%d", livePrefix(), index); +} + +inline void topicInventoryEnd(char* buf, size_t n) { + snprintf(buf, n, "%s/inventory/end", livePrefix()); +} + +inline void topicSelect(char* buf, size_t n) { + snprintf(buf, n, "%s/select", livePrefix()); +} + +inline void topicSelectAck(char* buf, size_t n) { + snprintf(buf, n, "%s/select/ack", livePrefix()); +} + +inline bool topicIsPattern(const char* topic) { + char expected[48]; + topicPattern(expected, sizeof(expected)); + return topic && strcmp(topic, expected) == 0; } inline bool topicIsMessage(const char* topic) { @@ -199,9 +310,21 @@ inline bool topicIsMessage(const char* topic) { return topic && strcmp(topic, expected) == 0; } -inline bool topicIsPattern(const char* topic) { +inline bool topicIsSnapshot(const char* topic) { char expected[48]; - topicPattern(expected, sizeof(expected)); + topicSnapshot(expected, sizeof(expected)); + return topic && strcmp(topic, expected) == 0; +} + +inline bool topicIsQuery(const char* topic) { + char expected[48]; + topicQuery(expected, sizeof(expected)); + return topic && strcmp(topic, expected) == 0; +} + +inline bool topicIsSelect(const char* topic) { + char expected[48]; + topicSelect(expected, sizeof(expected)); return topic && strcmp(topic, expected) == 0; } @@ -216,8 +339,17 @@ inline int topicKnobIndex(const char* topic) { return strcmp(topic, expected) == 0 ? (n - 1) : -1; } -// "/patterns/cell_ripple.pfm" → "cell_ripple". Empty for a preset, whose -// modulePath is null because its code lives in firmware.bin. +inline int topicParamIndex(const char* topic) { + if (!topic) return -1; + const char* slash = strrchr(topic, '/'); + if (!slash || !slash[1]) return -1; + int n = atoi(slash + 1); + if (n < 1 || n > 4) return -1; + char expected[48]; + topicParam(expected, sizeof(expected), n - 1); + return strcmp(topic, expected) == 0 ? (n - 1) : -1; +} + inline void slugFromModulePath(const char* path, char* out, size_t n) { if (!path || !path[0]) { out[0] = '\0'; return; } const char* filename = strrchr(path, '/'); @@ -226,9 +358,161 @@ inline void slugFromModulePath(const char* path, char* out, size_t n) { if (extension) *extension = '\0'; } -// Display name first (that is what we publish), then a case-insensitive -// pass, then the on-disk slug — so both "Cell Ripple" and "cell_ripple" -// select the same pattern from a shell or an automation rule. +inline void patternSlugAt(int index, char* out, size_t n) { + out[0] = '\0'; + if (index < 0 || index >= NUM_PATTERNS || !patterns) return; + slugFromModulePath(patterns[index].modulePath, out, n); + if (out[0]) return; + const char* name = patterns[index].name ? patterns[index].name : ""; + size_t j = 0; + for (size_t i = 0; name[i] && j + 1 < n; ++i) { + char c = name[i]; + if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); + if (c == ' ' || c == '-') c = '_'; + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') { + out[j++] = c; + } + } + out[j] = '\0'; +} + +inline void jsonEscape(const char* in, char* out, size_t n) { + size_t j = 0; + for (size_t i = 0; in && in[i] && j + 2 < n; ++i) { + unsigned char c = (unsigned char)in[i]; + if (c == '"' || c == '\\') { + if (j + 3 >= n) break; + out[j++] = '\\'; + out[j++] = (char)c; + } else if (c >= 0x20) { + out[j++] = (char)c; + } + } + out[j] = '\0'; +} + +inline void requestInventory() { + if (!isDirectorMode()) return; + inventorySendIdx = -1; +} + +inline int findPatternByName(const char* name); + +inline void publishSelectAck(uint8_t matched, uint8_t presets, uint8_t missing) { + if (!client.connected()) return; + char topic[56]; + char payload[128]; + topicSelectAck(topic, sizeof(topic)); + snprintf(payload, sizeof(payload), + "{\"matched\":%u,\"presets\":%u,\"missing\":%u,\"rev\":%lu,\"total\":%u}", + (unsigned)matched, (unsigned)presets, (unsigned)missing, + (unsigned long)PatternflowPackSelect::rev, + (unsigned)PatternflowPackSelect::count); + client.publish(topic, payload, false); +} + +// Director → panel: newline-separated names or slugs. "clear" empties the +// list. A leading '+' appends (chunked sends). Only installed .pfm modules +// are marked — presets live in firmware and are not ZIP-exported. +inline void applyPackSelectPayload(uint8_t* payload, unsigned int length) { + if (!isDirectorMode()) return; + char body[481]; + unsigned int n = length < sizeof(body) - 1 ? length : sizeof(body) - 1; + memcpy(body, payload, n); + body[n] = '\0'; + while (n && (body[n - 1] == '\n' || body[n - 1] == '\r' || body[n - 1] == ' ')) { + body[--n] = '\0'; + } + char* start = body; + while (*start == ' ' || *start == '\n' || *start == '\r') start++; + + if (!start[0] || strcasecmp(start, "clear") == 0) { + PatternflowPackSelect::clear(); + publishSelectAck(0, 0, 0); + return; + } + + bool append = false; + if (start[0] == '+') { + append = true; + start++; + } + if (!append) PatternflowPackSelect::clear(); + + uint8_t matched = 0, presets = 0, missing = 0; + char* p = start; + while (*p) { + while (*p == '\n' || *p == '\r' || *p == ',') p++; + if (!*p) break; + char* tok = p; + while (*p && *p != '\n' && *p != '\r' && *p != ',') p++; + char saved = *p; + *p = '\0'; + while (*tok == ' ') tok++; + char* end = tok + strlen(tok); + while (end > tok && end[-1] == ' ') *--end = '\0'; + if (tok[0] && strcasecmp(tok, "clear") != 0) { + int idx = findPatternByName(tok); + if (idx < 0) { + missing++; + } else if (!patterns[idx].modulePath) { + presets++; + } else { + char slug[MODULE_NAME_BYTES]; + patternSlugAt(idx, slug, sizeof(slug)); + if (slug[0] && PatternflowPackSelect::add(slug)) matched++; + else missing++; + } + } + *p = saved; + if (saved) p++; + } + PatternflowPackSelect::bump(); + Serial.printf("[MQTT] pack select matched=%u presets=%u missing=%u\n", + (unsigned)matched, (unsigned)presets, (unsigned)missing); + publishSelectAck(matched, presets, missing); +} + +inline void publishInventoryItem(int index) { + if (index < 0 || index >= NUM_PATTERNS || !patterns) return; + char topic[56]; + topicInventoryItem(topic, sizeof(topic), index); + char nameEsc[NAME_BYTES * 2]; + char slug[NAME_BYTES]; + jsonEscape(patterns[index].name ? patterns[index].name : "", nameEsc, sizeof(nameEsc)); + patternSlugAt(index, slug, sizeof(slug)); + char payload[256]; + snprintf(payload, sizeof(payload), + "{\"i\":%d,\"name\":\"%s\",\"slug\":\"%s\",\"absoluteReady\":%s}", + index, nameEsc, slug, + patterns[index].absoluteReady ? "true" : "false"); + client.publish(topic, payload, false); +} + +inline void pumpInventory() { + if (inventorySendIdx < -1 || !isDirectorMode() || !client.connected()) return; + if (inventorySendIdx == -1) { + char topic[48]; + char payload[40]; + topicInventory(topic, sizeof(topic)); + snprintf(payload, sizeof(payload), "{\"count\":%d}", NUM_PATTERNS); + client.publish(topic, payload, false); + inventorySendIdx = 0; + return; + } + if (inventorySendIdx < NUM_PATTERNS) { + publishInventoryItem(inventorySendIdx); + inventorySendIdx++; + return; + } + char topic[48]; + char payload[40]; + topicInventoryEnd(topic, sizeof(topic)); + snprintf(payload, sizeof(payload), "{\"count\":%d}", NUM_PATTERNS); + client.publish(topic, payload, false); + inventorySendIdx = -2; +} + inline int findPatternByName(const char* name) { if (!name || !name[0] || !patterns) return -1; for (int i = 0; i < NUM_PATTERNS; ++i) { @@ -245,14 +529,6 @@ inline int findPatternByName(const char* name) { return -1; } -// Remote knobs arrive as absolute counts; patterns consume deltas. -// -// The first value after (re)subscribing is the retained snapshot, and -// catching up to it means closing the gap against OUR knob, not adding the -// publisher's number to ours: a panel sitting at 100 receiving a retained -// 417 has to move +317. (Taking the remote value as the delta only looks -// right from a fresh boot, where the local count happens to be 0.) -// Afterwards it is ordinary differencing between successive remotes. inline void applyRemoteKnob(int index, long remote) { if (index < 0 || index > 3) return; int32_t delta; @@ -271,28 +547,154 @@ inline void applyRemotePattern(const char* name) { if (index >= 0) pendingPatternIdx = index; } -/** - * Show a banner, or clear it. - * - * An empty payload clears — which is also how you retract a retained one, so - * "publish empty with retain" both wipes the topic and takes the banner off - * every panel watching it. - */ -inline void applyRemoteMessage(const char* text) { +inline void applyRemoteParam(int index, long value) { + if (index < 0 || index > 3) return; + if (value < 0) value = 0; + if (value > 1000) value = 1000; + paramHeld[index] = true; + paramValue[index] = (uint16_t)value; + paramHeldAtMs[index] = millis(); +} + +inline void releaseAbsolute(int index) { + if (index < 0 || index > 3) return; + // Physical release only after grace — avoids encoder noise dropping Director holds. + if (paramHeld[index] && + (millis() - paramHeldAtMs[index]) < ABSOLUTE_RELEASE_GRACE_MS) { + return; + } + paramHeld[index] = false; +} + +inline void clearAbsoluteAll() { + for (int i = 0; i < 4; ++i) { + paramHeld[i] = false; + paramHeldAtMs[i] = 0; + } +} + +// Copy held absolute values into the frame. Call after physical release. +// When absolute is active, clear deltas / audio flags on that channel so +// legacy paths cannot fight the Director set-point. +inline void fillAbsolute(InputFrame& input) { + for (int i = 0; i < 4; ++i) { + input.paramAbsoluteActive[i] = paramHeld[i]; + input.paramAbsolute[i] = paramValue[i]; + if (paramHeld[i]) { + input.knobDeltas[i] = 0; + input.knobAudioActive[i] = false; + } + } +} + +inline void applyRemoteMessage(uint8_t* payload, unsigned int length) { + overlaySticky = false; + char body[MESSAGE_BYTES]; + unsigned int n = length < sizeof(body) - 1 ? length : sizeof(body) - 1; + memcpy(body, payload, n); + body[n] = '\0'; + while (n && (body[n - 1] == '\n' || body[n - 1] == '\r' || body[n - 1] == ' ')) { + body[--n] = '\0'; + } + if (!body[0]) { + overlayText[0] = '\0'; + overlayExpiresMs = 0; + return; + } + snprintf(overlayText, sizeof(overlayText), "%s", body); + overlayExpiresMs = millis() + PF_MQTT_MESSAGE_DURATION_MS; +} + +// Show player: banner holds until the next message cue (empty clears). +inline void applyHeldMessage(const char* text) { if (!text || !text[0]) { overlayText[0] = '\0'; - overlayUntilMs = 0; + overlayExpiresMs = 0; + overlaySticky = false; return; } snprintf(overlayText, sizeof(overlayText), "%s", text); - overlayUntilMs = millis() + PF_MQTT_MESSAGE_DURATION_MS; + overlaySticky = true; + overlayExpiresMs = 0; +} + +// Minimal snapshot parser — looks for "pattern", "param":[a,b,c,d], "message". +// Empty payload clears absolute holds and the banner (end-of-show sweep). +inline void applyRemoteSnapshot(uint8_t* payload, unsigned int length) { + if (!payload || length == 0) { + clearAbsoluteAll(); + overlayText[0] = '\0'; + overlayExpiresMs = 0; + return; + } + + char body[SNAPSHOT_BYTES]; + unsigned int n = length < sizeof(body) - 1 ? length : sizeof(body) - 1; + memcpy(body, payload, n); + body[n] = '\0'; + + const char* patKey = strstr(body, "\"pattern\""); + if (patKey) { + const char* colon = strchr(patKey + 9, ':'); + if (colon) { + const char* q1 = strchr(colon + 1, '"'); + if (q1) { + const char* q2 = strchr(q1 + 1, '"'); + if (q2 && q2 > q1 + 1) { + char name[NAME_BYTES]; + size_t len = (size_t)(q2 - (q1 + 1)); + if (len >= sizeof(name)) len = sizeof(name) - 1; + memcpy(name, q1 + 1, len); + name[len] = '\0'; + applyRemotePattern(name); + } + } + } + } + + const char* paramKey = strstr(body, "\"param\""); + if (paramKey) { + const char* bracket = strchr(paramKey + 7, '['); + if (bracket) { + const char* p = bracket + 1; + for (int i = 0; i < 4; ++i) { + while (*p == ' ' || *p == '\t' || *p == ',') ++p; + char* end = nullptr; + long v = strtol(p, &end, 10); + if (end == p) break; + applyRemoteParam(i, v); + p = end; + } + } + } + + const char* msgKey = strstr(body, "\"message\""); + if (msgKey) { + const char* colon = strchr(msgKey + 9, ':'); + if (colon) { + const char* q1 = strchr(colon + 1, '"'); + if (q1) { + const char* q2 = strchr(q1 + 1, '"'); + if (q2) { + char msg[MESSAGE_BYTES]; + size_t len = (size_t)(q2 - (q1 + 1)); + if (len >= sizeof(msg)) len = sizeof(msg) - 1; + memcpy(msg, q1 + 1, len); + msg[len] = '\0'; + applyRemoteMessage((uint8_t*)msg, (unsigned int)len); + } + } + } + } } inline bool overlayActive() { if (!overlayText[0]) return false; - // millis() wraps after ~49 days; the subtraction is what makes that safe. - if ((int32_t)(millis() - overlayUntilMs) >= 0) { + if (overlaySticky) return true; + if (overlayExpiresMs == 0) return false; + if ((int32_t)(millis() - overlayExpiresMs) >= 0) { overlayText[0] = '\0'; + overlayExpiresMs = 0; return false; } return true; @@ -300,13 +702,31 @@ inline bool overlayActive() { inline const char* overlayMessage() { return overlayText; } -/** Milliseconds left on the banner, for the console. */ inline uint32_t overlayRemainingMs() { if (!overlayActive()) return 0; - return overlayUntilMs - millis(); + return overlayExpiresMs - millis(); } inline void onMessage(char* topic, uint8_t* payload, unsigned int length) { + if (isDirectorMode() && topicIsQuery(topic)) { + requestInventory(); + return; + } + if (isDirectorMode() && topicIsSelect(topic)) { + applyPackSelectPayload(payload, length); + return; + } + if (topicIsMessage(topic)) { + applyRemoteMessage(payload, length); + return; + } + if (topicIsSnapshot(topic)) { + // Snapshot is useful for any connected role on a channel (mid-join). + applyRemoteSnapshot(payload, length); + return; + } + if (role != ROLE_SUBSCRIBER) return; + char body[96]; unsigned int n = length < sizeof(body) - 1 ? length : sizeof(body) - 1; memcpy(body, payload, n); @@ -315,21 +735,19 @@ inline void onMessage(char* topic, uint8_t* payload, unsigned int length) { body[--n] = '\0'; } - // Before the subscriber gate on purpose: a banner is worth having on a - // one-panel setup, where the panel is the publisher and Home Assistant is - // the only thing talking back. Mirroring knobs is the part that only makes - // sense as a subscriber. - if (topicIsMessage(topic)) { - applyRemoteMessage(body); - return; - } - - if (role != ROLE_SUBSCRIBER) return; - if (topicIsPattern(topic)) { applyRemotePattern(body); return; } + int param = topicParamIndex(topic); + if (param >= 0) { + if (!body[0]) { + releaseAbsolute(param); + } else { + applyRemoteParam(param, atol(body)); + } + return; + } int knob = topicKnobIndex(topic); if (knob >= 0) applyRemoteKnob(knob, atol(body)); } @@ -339,22 +757,58 @@ inline void publishKnob(int index, long clicks) { char payload[16]; topicKnob(topic, sizeof(topic), index); snprintf(payload, sizeof(payload), "%ld", clicks); - client.publish(topic, payload, true); + client.publish(topic, payload, retainLeafTopic()); } inline void publishPatternName(const char* name) { char topic[48]; topicPattern(topic, sizeof(topic)); - client.publish(topic, name ? name : "", true); + client.publish(topic, name ? name : "", retainLeafTopic()); +} + +inline void publishParam(int index, uint16_t value) { + char topic[48]; + char payload[8]; + topicParam(topic, sizeof(topic), index); + if (value > 1000) value = 1000; + snprintf(payload, sizeof(payload), "%u", (unsigned)value); + client.publish(topic, payload, retainLeafTopic()); +} + +inline void publishChannelSnapshot(bool force) { + if (!retainSnapshotTopic()) return; + if (role != ROLE_PUBLISHER || !client.connected()) return; + uint32_t now = millis(); + if (!force && lastSnapshotPubMs != 0 && + (now - lastSnapshotPubMs) < SNAPSHOT_HEARTBEAT_MS) { + return; + } + lastSnapshotPubMs = now; + + char topic[48]; + char payload[SNAPSHOT_BYTES]; + topicSnapshot(topic, sizeof(topic)); + snprintf(payload, sizeof(payload), + "{\"pattern\":\"%s\",\"param\":[%u,%u,%u,%u],\"message\":\"\"}", + lastPattern[0] ? lastPattern : "", + (unsigned)paramValue[0], (unsigned)paramValue[1], + (unsigned)paramValue[2], (unsigned)paramValue[3]); + client.publish(topic, payload, true); } -inline void publishSnapshot() { +// Live leaf burst for currently connected peers (non-retain). Channel +// mid-join uses retained snapshot instead. +inline void publishLiveBurst() { if (!client.connected() || role != ROLE_PUBLISHER) return; for (int i = 0; i < 4; ++i) publishKnob(i, lastKnobs[i]); if (lastPattern[0]) { publishPatternName(lastPattern); snprintf(publishedPattern, sizeof(publishedPattern), "%s", lastPattern); } + for (int i = 0; i < 4; ++i) { + if (paramHeld[i]) publishParam(i, paramValue[i]); + } + publishChannelSnapshot(true); } inline void ensureClientId() { @@ -364,14 +818,6 @@ inline void ensureClientId() { (unsigned)((mac >> 24) & 0xFFFFFF)); } -// Each knob topic by name, rather than one `knob/+` subscription. -// -// A wildcard needs the broker to grant the wildcard. An ACL written as the -// exact list of topics a device may touch — which is how you would lock down -// a broker shared with strangers, and how @SimonePDA's is configured — -// refuses `patternflow/knob/+` outright, and the failure is silent: the -// device connects, reports "connected", and simply never receives a knob. -// Four subscriptions cost four small packets once per connect. inline void subscribeMessage() { char topic[48]; topicMessage(topic, sizeof(topic)); @@ -379,29 +825,70 @@ inline void subscribeMessage() { } inline void subscribeAll() { + subscribeMessage(); char topic[48]; for (int i = 0; i < 4; ++i) { topicKnob(topic, sizeof(topic), i); client.subscribe(topic); + topicParam(topic, sizeof(topic), i); + client.subscribe(topic); } topicPattern(topic, sizeof(topic)); client.subscribe(topic); + if (isChannelShowPrefix(livePrefix())) { + topicSnapshot(topic, sizeof(topic)); + client.subscribe(topic); + } + if (isDirectorMode()) { + topicQuery(topic, sizeof(topic)); + client.subscribe(topic); + topicSelect(topic, sizeof(topic)); + client.subscribe(topic); + } +} + +// Publisher on a channel also listens for snapshot (ops clear) + message. +inline void subscribePublisherExtras() { subscribeMessage(); + if (isChannelShowPrefix(livePrefix())) { + char topic[48]; + topicSnapshot(topic, sizeof(topic)); + client.subscribe(topic); + } +} + +inline void unsubscribeAll() { + if (!client.connected() || livePrefix()[0] == '\0') return; + char topic[48]; + topicMessage(topic, sizeof(topic)); + client.unsubscribe(topic); + topicPattern(topic, sizeof(topic)); + client.unsubscribe(topic); + topicSnapshot(topic, sizeof(topic)); + client.unsubscribe(topic); + topicQuery(topic, sizeof(topic)); + client.unsubscribe(topic); + topicSelect(topic, sizeof(topic)); + client.unsubscribe(topic); + for (int i = 0; i < 4; ++i) { + topicKnob(topic, sizeof(topic), i); + client.unsubscribe(topic); + topicParam(topic, sizeof(topic), i); + client.unsubscribe(topic); + } } -// A literal address costs nothing to "resolve"; a hostname costs a blocking -// DNS query, and PubSubClient would pay it again on every connect attempt. -// Do it once and hand it the address. inline bool resolveBroker() { if (brokerResolved) return true; - if (brokerIp.fromString(cfgHost)) { + const char* host = connectHost(); + if (brokerIp.fromString(host)) { brokerResolved = true; return true; } - if (WiFi.hostByName(cfgHost, brokerIp) == 1) { + if (WiFi.hostByName(host, brokerIp) == 1) { brokerResolved = true; Serial.printf("[MQTT] %s resolved to %s\n", - cfgHost, brokerIp.toString().c_str()); + host, brokerIp.toString().c_str()); return true; } setError("cannot resolve broker"); @@ -409,9 +896,14 @@ inline bool resolveBroker() { } inline void applyClientSettings() { +#if defined(ESP_ARDUINO_VERSION) && \ + ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0) net.setConnectionTimeout(CONNECT_TIMEOUT_MS); +#else + net.setTimeout((CONNECT_TIMEOUT_MS + 999U) / 1000U); +#endif client.setCallback(onMessage); - client.setBufferSize(256); + client.setBufferSize(512); client.setKeepAlive(15); client.setSocketTimeout(SOCKET_TIMEOUT_S); } @@ -428,21 +920,19 @@ inline bool tryConnect() { ensureClientId(); applyClientSettings(); if (!resolveBroker()) { - if (++failures == 0) failures = RERESOLVE_AFTER; // never wrap to "healthy" + if (++failures == 0) failures = RERESOLVE_AFTER; return false; } - client.setServer(brokerIp, cfgPort); + client.setServer(brokerIp, connectPort()); bool ok; - if (cfgUser[0] != '\0') { + if (!isDirectorMode() && cfgUser[0] != '\0') { ok = client.connect(clientId, cfgUser, cfgPass); } else { ok = client.connect(clientId); } if (!ok) { if (++failures == 0) failures = RERESOLVE_AFTER; - // Enough refusals in a row and the address itself is suspect — drop it so - // the next attempt looks the name up again. if (failures >= RERESOLVE_AFTER) brokerResolved = false; snprintf(lastError, sizeof(lastError), "connect state %d", client.state()); Serial.printf("[MQTT] connect failed (%s), retry in %lus\n", @@ -452,54 +942,106 @@ inline bool tryConnect() { failures = 0; setError(""); - Serial.printf("[MQTT] connected as %s (%s) to %s:%d\n", - clientId, roleName(role), cfgHost, cfgPort); + Serial.printf("[MQTT] connected as %s (%s) mode=%s ch=%s prefix=%s to %s:%d\n", + clientId, roleName(role), + isDirectorMode() ? "director" : "normal", + channelName(channel), livePrefix(), + connectHost(), connectPort()); if (role == ROLE_SUBSCRIBER) { - // Forget remote baselines: the retained snapshot about to arrive is - // what we resync against. for (int i = 0; i < 4; ++i) haveKnob[i] = false; subscribeAll(); } else if (role == ROLE_PUBLISHER) { - publishSnapshot(); - // A publisher subscribes to exactly one thing: banners. It mirrors - // nobody's knobs, but there is no reason it should not be able to be - // told something. - subscribeMessage(); + subscribePublisherExtras(); + publishLiveBurst(); } return true; } -inline void setRole(Role next) { - if (next > ROLE_SUBSCRIBER) next = ROLE_OFF; - if (role == next) return; - role = next; +inline void resetSessionState() { for (int i = 0; i < 4; ++i) { haveKnob[i] = false; pendingKnobDelta[i] = 0; } pendingPatternIdx = -1; publishedPattern[0] = '\0'; - if (client.connected()) client.disconnect(); - // Picking a role on /mqtt is a deliberate "try now": clear the backoff so - // the user is not left waiting out a minute earned by an earlier outage. + overlayText[0] = '\0'; + overlayExpiresMs = 0; + overlaySticky = false; + lastSnapshotPubMs = 0; +} + +inline void setRole(Role next) { + if (next > ROLE_SUBSCRIBER) next = ROLE_OFF; + if (channelForcesSubscriber(channel) && next == ROLE_PUBLISHER) { + next = ROLE_SUBSCRIBER; + } + if (role == next) return; + if (client.connected()) { + unsubscribeAll(); + client.disconnect(); + } + role = next; + if (next == ROLE_OFF) channel = CHANNEL_OFF; + else if (channel == CHANNEL_OFF) channel = channelFromPrefix(cfgPrefix); + resetSessionState(); failures = 0; lastReconnectMs = 0; Serial.printf("[MQTT] role → %s\n", roleName(role)); } +inline void applyChannel(Channel next, Role preferredRole) { + Channel prev = channel; + char prevPrefix[PREFIX_BYTES]; + snprintf(prevPrefix, sizeof(prevPrefix), "%s", cfgPrefix); + + if (next == CHANNEL_OFF) { + channel = CHANNEL_OFF; + role = ROLE_OFF; + } else { + channel = next; + const char* preset = prefixForChannel(next); + if (preset) { + snprintf(cfgPrefix, sizeof(cfgPrefix), "%s", preset); + } + if (channelForcesSubscriber(next)) { + role = ROLE_SUBSCRIBER; + } else if (preferredRole == ROLE_OFF) { + role = ROLE_SUBSCRIBER; + } else { + role = preferredRole; + } + } + + if (client.connected()) { + unsubscribeAll(); + client.disconnect(); + } + resetSessionState(); + failures = 0; + lastReconnectMs = 0; + Serial.printf("[MQTT] channel → %s (was %s) prefix %s→%s role=%s\n", + channelName(channel), channelName(prev), + prevPrefix, cfgPrefix, roleName(role)); +} + inline Role currentRole() { return role; } +inline Channel currentChannel() { return channel; } inline bool isCompiledIn() { return true; } inline bool isConnected() { return client.connected(); } inline const char* error() { return lastError; } -inline const char* host() { return cfgHost; } -inline uint16_t port() { return cfgPort; } -inline const char* user() { return cfgUser; } -inline const char* prefix() { return cfgPrefix; } -/** Whether a password is set — the value itself never leaves the device. */ -inline bool hasPassword() { return cfgPass[0] != '\0'; } +inline const char* host() { return connectHost(); } +inline uint16_t port() { return connectPort(); } +inline const char* user() { return isDirectorMode() ? "" : cfgUser; } +inline const char* prefix() { return livePrefix(); } +inline bool hasPassword() { return !isDirectorMode() && cfgPass[0] != '\0'; } inline const char* lastPatternName() { return lastPattern; } - -// ── Settings persistence ───────────────────────────────────────────────── +inline const char* normalHost() { return cfgHost; } +inline const char* directorHost() { return dirHost; } +inline const char* modeName() { return isDirectorMode() ? "director" : "normal"; } +inline uint16_t normalPort() { return cfgPort; } +inline const char* normalUser() { return cfgUser; } +inline const char* normalPrefix() { return cfgPrefix; } +inline bool normalHasPassword() { return cfgPass[0] != '\0'; } inline void copyField(char* dest, size_t size, const char* src) { if (!src) src = ""; @@ -507,13 +1049,83 @@ inline void copyField(char* dest, size_t size, const char* src) { dest[size - 1] = '\0'; } -/** - * Load saved settings over the compiled-in defaults. - * - * Each key is read only if it exists, so a device that has saved a host but - * never touched the prefix keeps the built-in prefix rather than being handed - * an empty string. - */ +inline void applyConfigChange(); + +inline void persistMqttMode() { + Preferences prefs; + if (!prefs.begin("patternflow", false)) return; + prefs.putUChar("mq_mode", (uint8_t)mqttMode); + prefs.putString("mq_dirhost", dirHost); + prefs.end(); +} + +inline void applyDirectorOverlay() { + channel = CHANNEL_BROADCAST; + role = ROLE_SUBSCRIBER; +} + +inline void restoreNormalChannelAndRole() { + Preferences prefs; + Channel ch = CHANNEL_OFF; + Role savedRole = ROLE_OFF; + if (prefs.begin("patternflow", true)) { + ch = (Channel)prefs.getUChar("mq_channel", (uint8_t)CHANNEL_OFF); + savedRole = (Role)prefs.getUChar("mqtt_role", (uint8_t)ROLE_OFF); + prefs.end(); + } + if (savedRole == ROLE_OFF || ch == CHANNEL_OFF) { + applyChannel(CHANNEL_OFF, ROLE_OFF); + } else { + applyChannel(ch, savedRole); + } +} + +inline bool setDirectorHost(const char* host) { + copyField(dirHost, sizeof(dirHost), host); + persistMqttMode(); + if (isDirectorMode()) applyConfigChange(); + return dirHost[0] != '\0'; +} + +inline void setMqttMode(MqttMode next) { + if (next == MQTT_MODE_DIRECTOR && dirHost[0] == '\0') { + mqttMode = MQTT_MODE_DIRECTOR; + persistMqttMode(); + applyDirectorOverlay(); + applyConfigChange(); + setError("set Director PC IP"); + return; + } + if (mqttMode == next) { + if (next == MQTT_MODE_DIRECTOR) applyDirectorOverlay(); + return; + } + mqttMode = next; + persistMqttMode(); + if (next == MQTT_MODE_DIRECTOR) { + applyDirectorOverlay(); + applyConfigChange(); + } else { + restoreNormalChannelAndRole(); + applyConfigChange(); + } + Serial.printf("[MQTT] mode → %s\n", modeName()); +} + +inline void applySavedMode() { + if (mqttMode == MQTT_MODE_DIRECTOR) applyDirectorOverlay(); +} + +inline void persistChannelAndRole() { + if (isDirectorMode()) return; + Preferences prefs; + if (!prefs.begin("patternflow", false)) return; + prefs.putUChar("mqtt_role", (uint8_t)role); + prefs.putUChar("mq_channel", (uint8_t)channel); + prefs.putString("mq_prefix", cfgPrefix); + prefs.end(); +} + inline void loadConfig() { Preferences prefs; if (!prefs.begin("patternflow", true)) return; @@ -522,27 +1134,35 @@ inline void loadConfig() { if (prefs.isKey("mq_pass")) prefs.getString("mq_pass", cfgPass, sizeof(cfgPass)); if (prefs.isKey("mq_prefix")) prefs.getString("mq_prefix", cfgPrefix, sizeof(cfgPrefix)); if (prefs.isKey("mq_port")) cfgPort = prefs.getUShort("mq_port", cfgPort); + if (prefs.isKey("mq_dirhost")) prefs.getString("mq_dirhost", dirHost, sizeof(dirHost)); + if (prefs.isKey("mq_mode")) { + mqttMode = (MqttMode)prefs.getUChar("mq_mode", (uint8_t)MQTT_MODE_NORMAL); + } + if (prefs.isKey("mq_channel")) { + channel = (Channel)prefs.getUChar("mq_channel", (uint8_t)CHANNEL_BROADCAST); + } else { + channel = channelFromPrefix(cfgPrefix); + } prefs.end(); + if (channel == CHANNEL_OFF) { + role = ROLE_OFF; + } else if (channelForcesSubscriber(channel)) { + // Role may still be loaded separately; force Sub on timed channels. + } } -/** Drop the connection so the next attempt uses the new settings. */ inline void applyConfigChange() { brokerResolved = false; failures = 0; lastReconnectMs = 0; lastError[0] = '\0'; for (int i = 0; i < 4; ++i) haveKnob[i] = false; - if (client.connected()) client.disconnect(); + if (client.connected()) { + unsubscribeAll(); + client.disconnect(); + } } -/** - * Save what was entered on /mqtt. - * - * A null password means "leave it alone", which is what lets the page show a - * form without ever having been sent the current one — the field arrives - * empty and an empty field must not wipe a working login. Clearing is a - * separate, explicit act (see the clear route). - */ inline void saveConfig(const char* newHost, uint16_t newPort, const char* newUser, const char* newPass, const char* newPrefix) { copyField(cfgHost, sizeof(cfgHost), newHost); @@ -551,30 +1171,45 @@ inline void saveConfig(const char* newHost, uint16_t newPort, const char* newUse cfgPort = newPort ? newPort : 1883; if (newPass) copyField(cfgPass, sizeof(cfgPass), newPass); + Channel detected = channelFromPrefix(cfgPrefix); + if (detected != CHANNEL_CUSTOM) { + channel = detected; + if (channelForcesSubscriber(channel) && role == ROLE_PUBLISHER) { + role = ROLE_SUBSCRIBER; + } + } else if (channel != CHANNEL_OFF) { + channel = CHANNEL_CUSTOM; + } + Preferences prefs; if (prefs.begin("patternflow", false)) { prefs.putString("mq_host", cfgHost); prefs.putString("mq_user", cfgUser); prefs.putString("mq_prefix", cfgPrefix); prefs.putUShort("mq_port", cfgPort); + prefs.putUChar("mq_channel", (uint8_t)channel); + prefs.putUChar("mqtt_role", (uint8_t)role); if (newPass) prefs.putString("mq_pass", cfgPass); prefs.end(); } applyConfigChange(); } -/** Forget the broker entirely, including the password. */ inline void clearConfig() { cfgHost[0] = '\0'; cfgUser[0] = '\0'; cfgPass[0] = '\0'; cfgPort = 1883; + snprintf(cfgPrefix, sizeof(cfgPrefix), "%s", "patternflow"); + channel = CHANNEL_BROADCAST; Preferences prefs; if (prefs.begin("patternflow", false)) { prefs.remove("mq_host"); prefs.remove("mq_user"); prefs.remove("mq_pass"); prefs.remove("mq_port"); + prefs.putString("mq_prefix", cfgPrefix); + prefs.putUChar("mq_channel", (uint8_t)channel); prefs.end(); } applyConfigChange(); @@ -584,6 +1219,13 @@ inline void lastKnobsCopy(long out[4]) { for (int i = 0; i < 4; ++i) out[i] = lastKnobs[i]; } +inline void lastParamsCopy(uint16_t out[4], bool activeOut[4]) { + for (int i = 0; i < 4; ++i) { + out[i] = paramValue[i]; + activeOut[i] = paramHeld[i]; + } +} + inline void begin() { if (WiFi.status() != WL_CONNECTED) return; started = true; @@ -592,15 +1234,16 @@ inline void begin() { applyClientSettings(); lastReconnectMs = 0; if (hasBroker()) { - Serial.printf("[MQTT] ready — broker %s:%d page /mqtt\n", - cfgHost, cfgPort); + Serial.printf("[MQTT] ready — %s broker %s:%d page /mqtt\n", + isDirectorMode() ? "director" : "normal", + connectHost(), connectPort()); } else { Serial.println("[MQTT] compiled in, no broker set — see /mqtt"); } } inline void handle() { - if (!started || role == ROLE_OFF || !hasBroker()) { + if (!started || role == ROLE_OFF || channel == CHANNEL_OFF || !hasBroker()) { if (client.connected()) client.disconnect(); return; } @@ -614,6 +1257,8 @@ inline void handle() { return; } client.loop(); + pumpInventory(); + if (role == ROLE_PUBLISHER) publishChannelSnapshot(false); } inline int32_t consumeKnobDelta(int idx) { @@ -630,8 +1275,6 @@ inline bool consumePatternIdx(int& outIdx) { return true; } -// Called every frame: keeps the status page honest even when off, and -// publishes only the knobs that actually moved. inline void update(const InputFrame& input, const char* contentName) { for (int i = 0; i < 4; ++i) lastKnobs[i] = input.knobs[i]; if (contentName) snprintf(lastPattern, sizeof(lastPattern), "%s", contentName); @@ -642,14 +1285,13 @@ inline void update(const InputFrame& input, const char* contentName) { } } -// Separate from update() because the pattern name changes far less often -// than the knobs, and republishing it every frame would hammer the broker. inline void notePattern(const char* contentName) { if (contentName) snprintf(lastPattern, sizeof(lastPattern), "%s", contentName); if (role != ROLE_PUBLISHER || !client.connected() || !lastPattern[0]) return; if (strcmp(publishedPattern, lastPattern) == 0) return; snprintf(publishedPattern, sizeof(publishedPattern), "%s", lastPattern); publishPatternName(lastPattern); + publishChannelSnapshot(true); } #else @@ -659,11 +1301,25 @@ inline void handle() {} inline void update(const InputFrame&, const char*) {} inline void notePattern(const char*) {} inline void setRole(Role) {} +inline void applyChannel(Channel, Role) {} +inline void loadConfig() {} +inline void saveConfig(const char*, uint16_t, const char*, const char*, const char*) {} +inline void clearConfig() {} +inline void persistChannelAndRole() {} +inline void setMqttMode(MqttMode) {} +inline bool setDirectorHost(const char*) { return false; } +inline void applySavedMode() {} inline Role currentRole() { return ROLE_OFF; } +inline Channel currentChannel() { return CHANNEL_OFF; } inline bool isCompiledIn() { return false; } inline bool isConnected() { return false; } inline bool hasBroker() { return false; } +inline bool hasPassword() { return false; } +inline bool isDirectorMode() { return false; } inline const char* roleName(Role) { return "off"; } +inline const char* channelName(Channel) { return "off"; } +inline Channel channelFromName(const char*) { return CHANNEL_OFF; } +inline bool channelForcesSubscriber(Channel) { return false; } inline const char* stateText() { return "off (compile-time)"; } inline const char* error() { return ""; } inline const char* host() { return ""; } @@ -671,11 +1327,37 @@ inline uint16_t port() { return 0; } inline const char* user() { return ""; } inline const char* prefix() { return ""; } inline const char* lastPatternName() { return ""; } +inline const char* normalHost() { return ""; } +inline const char* directorHost() { return ""; } +inline const char* modeName() { return "normal"; } +inline uint16_t normalPort() { return 0; } +inline const char* normalUser() { return ""; } +inline const char* normalPrefix() { return ""; } +inline bool normalHasPassword() { return false; } inline void lastKnobsCopy(long out[4]) { for (int i = 0; i < 4; ++i) out[i] = 0; } +inline void lastParamsCopy(uint16_t out[4], bool activeOut[4]) { + for (int i = 0; i < 4; ++i) { + out[i] = 0; + activeOut[i] = false; + } +} inline int32_t consumeKnobDelta(int) { return 0; } -inline bool consumePatternIdx(int&) { return false; } +inline bool consumePatternIdx(int& outIdx) { return false; } +inline void applyRemoteParam(int, long) {} +inline void applyHeldMessage(const char*) {} +inline void clearAbsoluteAll() {} +inline bool overlayActive() { return false; } +inline const char* overlayMessage() { return ""; } +inline uint32_t overlayRemainingMs() { return 0; } +inline void releaseAbsolute(int) {} +inline void fillAbsolute(InputFrame& input) { + for (int i = 0; i < 4; ++i) { + input.paramAbsoluteActive[i] = false; + input.paramAbsolute[i] = 0; + } +} #endif diff --git a/firmware/patternflow/src/core_mqtt_http.h b/firmware/patternflow/src/core_mqtt_http.h index 907784f..643bf51 100644 --- a/firmware/patternflow/src/core_mqtt_http.h +++ b/firmware/patternflow/src/core_mqtt_http.h @@ -1,36 +1,25 @@ // ═══════════════════════════════════════════════════════════ -// PatternFlow - MQTT role page (/mqtt) +// PatternFlow - MQTT role + channel page (/mqtt) // -// GET /mqtt role picker + live connection state +// GET /mqtt channel picker + role + broker form // GET /api/mqtt status JSON -// POST /api/mqtt role=off|publisher|subscriber (persisted in NVS) -// -// The broker itself is compile-time (patternflow_secrets.h): this page only -// chooses what the device DOES with it. Credentials are never sent to the -// browser — the JSON reports the host and user so you can confirm which -// broker is configured, and nothing else. -// -// Rides the patterns manager's WebServer, the same way core_display_http.h -// rides the status one, so the device still runs a single server on port 80. -// -// From Simone Majocchi's (@SimonePDA) Patternflow fork — see core_mqtt.h. +// POST /api/mqtt role=… and/or channel=… +// POST /api/mqtt/config broker fields (Normal mode) +// POST /api/mqtt/director host=LAN-IP (Director mode overlay) +// POST /api/mqtt/mode mode=normal|director +// POST /api/mqtt/forget // // License: MIT // ═══════════════════════════════════════════════════════════ #pragma once #include "../net_config.h" -// Pulled in BEFORE the gate below, not inside it: PF_PATTERNS_HTTP_ENABLED is -// defined by that header rather than by net_config.h, so testing it first -// would read an undefined macro as 0 and silently drop this whole page. -#include "core_patterns_http.h" // shared WebServer + console-wake helpers +#include "core_patterns_http.h" #ifndef PF_MQTT_HTTP_ENABLED #define PF_MQTT_HTTP_ENABLED PF_MQTT_ENABLED #endif -// No patterns manager means no server to ride and no console-wake helpers, -// so the page cannot exist on its own. #if PF_MQTT_HTTP_ENABLED && PF_PATTERNS_HTTP_ENABLED #include #include @@ -54,12 +43,14 @@ inline void persistRole(PatternflowMqtt::Role role) { prefs.end(); } -// Escaped, because not every value here is ours any more. The banner text -// arrives from whoever can publish to /message, and a single quote -// character in it would otherwise end the string early and hand the console a -// JSON parse error — which looks like the device being broken rather than -// somebody having typed an apostrophe. Pattern names get the same treatment; -// they come from a community and nothing stops one containing a quote. +inline void persistChannelAndRole() { + PatternflowMqtt::persistChannelAndRole(); +} + +// Escaped, because not every value here is ours. Pattern names come from a +// community and nothing stops one containing a quote — unescaped it would end +// the JSON string early and hand the page a parse error that looks like the +// device being broken. inline void appendJsonString(String& json, const char* key, const char* value) { json += "\""; json += key; @@ -70,9 +61,6 @@ inline void appendJsonString(String& json, const char* key, const char* value) { json += '\\'; json += (char)c; } else if (c < 0x20) { - // Control characters are not legal raw in a JSON string. None of them - // mean anything on a 64px panel, so they become spaces rather than - // \u00xx escapes nobody will read. json += ' '; } else { json += (char)c; @@ -84,38 +72,57 @@ inline void appendJsonString(String& json, const char* key, const char* value) { inline void sendStatus(int code) { long knobs[4]; PatternflowMqtt::lastKnobsCopy(knobs); + uint16_t params[4]; + bool paramActive[4]; + PatternflowMqtt::lastParamsCopy(params, paramActive); String json = "{\"ok\":true,"; appendJsonString(json, "role", PatternflowMqtt::roleName(PatternflowMqtt::currentRole())); + appendJsonString(json, "channel", + PatternflowMqtt::channelName(PatternflowMqtt::currentChannel())); appendJsonString(json, "state", PatternflowMqtt::stateText()); appendJsonString(json, "host", PatternflowMqtt::host()); appendJsonString(json, "user", PatternflowMqtt::user()); appendJsonString(json, "prefix", PatternflowMqtt::prefix()); appendJsonString(json, "pattern", PatternflowMqtt::lastPatternName()); - appendJsonString(json, "message", PatternflowMqtt::overlayMessage()); appendJsonString(json, "error", PatternflowMqtt::error()); - json += "\"port\":"; + appendJsonString(json, "mode", PatternflowMqtt::modeName()); + appendJsonString(json, "directorHost", PatternflowMqtt::directorHost()); + appendJsonString(json, "normalHost", PatternflowMqtt::normalHost()); + appendJsonString(json, "normalUser", PatternflowMqtt::normalUser()); + appendJsonString(json, "normalPrefix", PatternflowMqtt::normalPrefix()); + json += "\"normalPort\":"; + json += PatternflowMqtt::normalPort(); + json += ",\"normalHasPassword\":"; + json += PatternflowMqtt::normalHasPassword() ? "true" : "false"; + json += ",\"port\":"; json += PatternflowMqtt::port(); json += ",\"connected\":"; json += PatternflowMqtt::isConnected() ? "true" : "false"; json += ",\"configured\":"; json += PatternflowMqtt::hasBroker() ? "true" : "false"; - // Whether a password is set, never the password. The page shows an empty - // field either way and sending it back would put a credential on the - // network on every poll for no benefit — nothing on the page needs to - // read it, only to replace it. json += ",\"hasPassword\":"; json += PatternflowMqtt::hasPassword() ? "true" : "false"; - // The banner currently on the panel, so the page can show what a message - // did without anyone having to be in the room. - json += ",\"messageMs\":"; - json += PatternflowMqtt::overlayRemainingMs(); + json += ",\"forcesSub\":"; + json += PatternflowMqtt::channelForcesSubscriber(PatternflowMqtt::currentChannel()) + ? "true" + : "false"; json += ",\"knobs\":["; for (int i = 0; i < 4; ++i) { if (i) json += ','; json += knobs[i]; } + json += "],\"params\":["; + for (int i = 0; i < 4; ++i) { + if (i) json += ','; + json += params[i]; + } + json += "],\"paramActive\":["; + for (int i = 0; i < 4; ++i) { + if (i) json += ','; + json += paramActive[i] ? "true" : "false"; + } json += "]}"; server().sendHeader("Cache-Control", "no-store"); @@ -137,39 +144,81 @@ inline void handleGet() { inline void handlePost() { PatternflowPatternsHttp::noteConsoleApiCall(); - String roleArg = server().hasArg("role") ? server().arg("role") : String(); - roleArg.toLowerCase(); + if (PatternflowMqtt::isDirectorMode()) { + server().send(400, "application/json", + "{\"ok\":false,\"error\":\"leave Director mode to change channel or role\"}"); + return; + } + + const bool hasChannel = server().hasArg("channel"); + const bool hasRole = server().hasArg("role"); + + if (!hasChannel && !hasRole) { + server().send(400, "application/json", + "{\"ok\":false,\"error\":\"channel or role required\"}"); + return; + } + + if (hasChannel) { + String chArg = server().arg("channel"); + chArg.toLowerCase(); + PatternflowMqtt::Channel ch = PatternflowMqtt::channelFromName(chArg.c_str()); + PatternflowMqtt::Role preferred = PatternflowMqtt::currentRole(); + if (hasRole) { + String roleArg = server().arg("role"); + roleArg.toLowerCase(); + if (roleArg == "publisher" || roleArg == "pub") { + preferred = PatternflowMqtt::ROLE_PUBLISHER; + } else if (roleArg == "subscriber" || roleArg == "sub") { + preferred = PatternflowMqtt::ROLE_SUBSCRIBER; + } else if (roleArg == "off" || roleArg == "none") { + preferred = PatternflowMqtt::ROLE_OFF; + } + } + if (ch == PatternflowMqtt::CHANNEL_CUSTOM && preferred == PatternflowMqtt::ROLE_OFF) { + preferred = PatternflowMqtt::ROLE_SUBSCRIBER; + } + PatternflowMqtt::applyChannel(ch, preferred); + persistChannelAndRole(); + sendStatus(200); + return; + } + + String roleArg = server().arg("role"); + roleArg.toLowerCase(); PatternflowMqtt::Role next; if (roleArg == "publisher" || roleArg == "pub") { next = PatternflowMqtt::ROLE_PUBLISHER; } else if (roleArg == "subscriber" || roleArg == "sub") { next = PatternflowMqtt::ROLE_SUBSCRIBER; } else if (roleArg == "off" || roleArg == "none" || roleArg.length() == 0) { - next = PatternflowMqtt::ROLE_OFF; + PatternflowMqtt::applyChannel(PatternflowMqtt::CHANNEL_OFF, PatternflowMqtt::ROLE_OFF); + persistChannelAndRole(); + sendStatus(200); + return; } else { server().send(400, "application/json", "{\"ok\":false,\"error\":\"role must be off, publisher, or subscriber\"}"); return; } - PatternflowMqtt::setRole(next); - persistRole(next); + PatternflowMqtt::Channel ch = PatternflowMqtt::currentChannel(); + if (ch == PatternflowMqtt::CHANNEL_OFF) ch = PatternflowMqtt::CHANNEL_BROADCAST; + PatternflowMqtt::applyChannel(ch, next); + persistChannelAndRole(); sendStatus(200); } -/** - * Save the broker settings typed on /mqtt. - * - * The password is the only field with a rule of its own: it is never sent to - * the page, so the form always posts it empty unless somebody typed a new - * one. An empty field therefore means "unchanged", not "blank it" — clearing - * is what the separate forget button is for. Without that, opening the page - * and pressing Save would silently drop a working login. - */ inline void handleConfig() { PatternflowPatternsHttp::noteConsoleApiCall(); + if (PatternflowMqtt::isDirectorMode()) { + server().send(400, "application/json", + "{\"ok\":false,\"error\":\"leave Director mode to edit the Normal broker\"}"); + return; + } + String host = server().hasArg("host") ? server().arg("host") : String(); host.trim(); @@ -191,16 +240,47 @@ inline void handleConfig() { PatternflowMqtt::saveConfig(host.c_str(), (uint16_t)port, user.c_str(), sentPassword ? pass.c_str() : nullptr, prefix.c_str()); + persistChannelAndRole(); + sendStatus(200); +} + +inline void handleDirector() { + PatternflowPatternsHttp::noteConsoleApiCall(); + String host = server().hasArg("host") ? server().arg("host") : String(); + host.trim(); + if (!host.length()) { + server().send(400, "application/json", + "{\"ok\":false,\"error\":\"Director PC IP is required\"}"); + return; + } + PatternflowMqtt::setDirectorHost(host.c_str()); + PatternflowMqtt::setMqttMode(PatternflowMqtt::MQTT_MODE_DIRECTOR); sendStatus(200); } +inline void handleMode() { + PatternflowPatternsHttp::noteConsoleApiCall(); + String mode = server().hasArg("mode") ? server().arg("mode") : String(); + mode.toLowerCase(); + if (mode == "director") { + PatternflowMqtt::setMqttMode(PatternflowMqtt::MQTT_MODE_DIRECTOR); + sendStatus(200); + return; + } + if (mode == "normal") { + PatternflowMqtt::setMqttMode(PatternflowMqtt::MQTT_MODE_NORMAL); + sendStatus(200); + return; + } + server().send(400, "application/json", + "{\"ok\":false,\"error\":\"mode must be director or normal\"}"); +} + inline void handleForget() { PatternflowPatternsHttp::noteConsoleApiCall(); PatternflowMqtt::clearConfig(); - // A forgotten broker with a role still set would retry an empty host - // forever, so the role goes back to Off with it. - PatternflowMqtt::setRole(PatternflowMqtt::ROLE_OFF); - persistRole(PatternflowMqtt::ROLE_OFF); + PatternflowMqtt::applyChannel(PatternflowMqtt::CHANNEL_OFF, PatternflowMqtt::ROLE_OFF); + persistChannelAndRole(); sendStatus(200); } @@ -212,6 +292,8 @@ inline void begin() { server().on("/api/mqtt", HTTP_GET, handleGet); server().on("/api/mqtt", HTTP_POST, handlePost); server().on("/api/mqtt/config", HTTP_POST, handleConfig); + server().on("/api/mqtt/director", HTTP_POST, handleDirector); + server().on("/api/mqtt/mode", HTTP_POST, handleMode); server().on("/api/mqtt/forget", HTTP_POST, handleForget); initialized = true; diff --git a/firmware/patternflow/src/core_pack_select.h b/firmware/patternflow/src/core_pack_select.h new file mode 100644 index 0000000..a0a2e36 --- /dev/null +++ b/firmware/patternflow/src/core_pack_select.h @@ -0,0 +1,49 @@ +// ═══════════════════════════════════════════════════════════ +// PatternFlow - Director-marked modules for /patterns ZIP export +// +// MQTT (Director mode) writes this list; the Patterns page reads it and +// checks those rows. RAM only — send again after reboot. +// License: MIT +// ═══════════════════════════════════════════════════════════ +#pragma once + +#include +#include "../pattern_registry.h" + +namespace PatternflowPackSelect { + +constexpr uint8_t MAX = 64; + +inline uint32_t rev = 0; +inline uint8_t count = 0; +inline char slugs[MAX][MODULE_NAME_BYTES] = {}; + +inline void bump() { + rev++; + if (rev == 0) rev = 1; +} + +inline void clear() { + count = 0; + bump(); +} + +inline bool has(const char* slug) { + if (!slug || !slug[0]) return false; + for (uint8_t i = 0; i < count; i++) { + if (strcasecmp(slugs[i], slug) == 0) return true; + } + return false; +} + +// True if the slug is now in the list (already present or newly added). +inline bool add(const char* slug) { + if (!slug || !slug[0]) return false; + if (has(slug)) return true; + if (count >= MAX) return false; + snprintf(slugs[count], MODULE_NAME_BYTES, "%s", slug); + count++; + return true; +} + +} // namespace PatternflowPackSelect diff --git a/firmware/patternflow/src/core_params.h b/firmware/patternflow/src/core_params.h new file mode 100644 index 0000000..27aaf4b --- /dev/null +++ b/firmware/patternflow/src/core_params.h @@ -0,0 +1,3 @@ +// Firmware wrapper — absolute param helpers live in abi/ so modules share them. +#pragma once +#include "../abi/pf_params.h" diff --git a/firmware/patternflow/src/core_patterns_http.h b/firmware/patternflow/src/core_patterns_http.h index fd09c14..8cef105 100644 --- a/firmware/patternflow/src/core_patterns_http.h +++ b/firmware/patternflow/src/core_patterns_http.h @@ -8,6 +8,8 @@ // Routes (shares the audio-react server, so one port-80 server total): // GET /patterns management page // GET /api/patterns JSON list (presets + modules) +// GET /api/patterns/pending Director-marked slugs for ZIP export +// GET /api/patterns/file?slug=&ext=pfm|json // POST /api/patterns multipart upload of a .pfm / .json // DELETE /api/patterns?slug= // POST /api/patterns/delete body: one slug per line, or "*" for all @@ -33,6 +35,7 @@ #include "core_audio_ws.h" #endif #include "core_send.h" // low-heap page sender — pages serve WITHOUT pausing the pattern +#include "core_pack_select.h" #include "patterns_index.h" #include "fflate_js.h" #endif @@ -81,21 +84,37 @@ inline char restorePath[MODULE_PATH_BYTES] = {}; inline int restorePresetIdx = -1; inline bool restorePending = false; +inline void evictResidentModule() { + if (!PFModuleLoader::active) return; + PFModuleLoader::unload(); + if (activePatternIdx >= 0 && patterns && patterns[activePatternIdx].modulePath) { + activePatternIdx = -1; + } +} + inline void captureSelectionOnce() { - if (restorePending) return; + if (restorePending) { + // Show / night schedule / MQTT may reload a module while the console + // still holds the pause. Evict again or the wake page loops forever. + evictResidentModule(); + return; + } restorePending = true; restorePath[0] = '\0'; restorePresetIdx = -1; if (activePatternIdx >= 0 && patterns) { if (patterns[activePatternIdx].modulePath) { snprintf(restorePath, sizeof(restorePath), "%s", patterns[activePatternIdx].modulePath); + // Free the module's executable+data RAM before any body bytes arrive. + evictResidentModule(); } else { + // Compiled-in presets (Origin, Weather, …) keep running — the console + // pause exists to reclaim module DRAM, which they do not use. restorePresetIdx = activePatternIdx; } + } else { + evictResidentModule(); } - // Free the module's executable+data RAM before any body bytes arrive. - PFModuleLoader::unload(); - activePatternIdx = -1; } inline void restoreSelection() { @@ -132,6 +151,13 @@ inline void requestReload() { reloadRequestedAtMs = millis(); } // evicted. The sketch draws a PAUSED screen instead of a torn frame. inline bool isConsolePaused() { return restorePending; } +// Play Now / wake alarm owns the panel. Do not snap back to whatever was +// running before the console opened. +inline void releaseConsolePause() { + restorePending = false; + lastConsoleActivityMs = 0; +} + // A resident module and the web console cannot both have the RAM they need. // Opening a console page evicts the module (pausing the pattern); tick() // restores it once the console has been idle. This was removed for one day @@ -147,6 +173,9 @@ inline bool noteConsolePageOpened() { lastConsoleActivityMs = millis(); const bool hadModule = PFModuleLoader::active != nullptr; captureSelectionOnce(); + // If something put a module back before this return, drop it again so the + // wake reload can actually serve the console. + if (PFModuleLoader::active) evictResidentModule(); return hadModule; } @@ -293,10 +322,79 @@ inline void handleList() { } json += '}'; } + json += "],\"pendingRev\":"; + json += PatternflowPackSelect::rev; + json += ",\"pending\":["; + for (uint8_t i = 0; i < PatternflowPackSelect::count; i++) { + if (i) json += ','; + json += '"'; + json += PatternflowPackSelect::slugs[i]; + json += '"'; + } json += "]}"; sendJson(200, json); } +inline void handlePendingSelect() { + noteConsoleApiCall(); + String json = "{\"rev\":"; + json += PatternflowPackSelect::rev; + json += ",\"slugs\":["; + for (uint8_t i = 0; i < PatternflowPackSelect::count; i++) { + if (i) json += ','; + json += '"'; + json += PatternflowPackSelect::slugs[i]; + json += '"'; + } + json += "]}"; + sendJson(200, json); +} + +// Browser ZIP export fetches one file at a time (this server is +// one-connection). Slug is sanitized the same way uploads are. +inline void handleFile() { + noteConsoleApiCall(); + if (!moduleStorageMounted) { + sendJson(409, "{\"ok\":false,\"error\":\"storage not mounted\"}"); + return; + } + if (!server().hasArg("slug")) { + sendJson(400, "{\"ok\":false,\"error\":\"missing slug\"}"); + return; + } + char slug[MODULE_NAME_BYTES]; + if (!slugFromFilename(server().arg("slug") + ".pfm", slug, sizeof(slug))) { + sendJson(400, "{\"ok\":false,\"error\":\"invalid slug\"}"); + return; + } + String ext = server().hasArg("ext") ? server().arg("ext") : String("pfm"); + ext.toLowerCase(); + if (ext != "pfm" && ext != "json") { + sendJson(400, "{\"ok\":false,\"error\":\"ext must be pfm or json\"}"); + return; + } + char path[MODULE_PATH_BYTES]; + snprintf(path, sizeof(path), "%s/%s.%s", MODULE_DIR, slug, ext.c_str()); + if (!FFat.exists(path)) { + sendJson(404, "{\"ok\":false,\"error\":\"not found\"}"); + return; + } + File file = FFat.open(path, "r"); + if (!file) { + sendJson(500, "{\"ok\":false,\"error\":\"cannot open\"}"); + return; + } + char disposition[80]; + snprintf(disposition, sizeof(disposition), + "attachment; filename=\"%s.%s\"", slug, ext.c_str()); + server().sendHeader("Content-Disposition", disposition); + server().sendHeader("Cache-Control", "no-store"); + const char* ctype = + ext == "json" ? "application/json" : "application/octet-stream"; + server().streamFile(file, ctype); + file.close(); +} + // Remove one module's files. Shared by the single and the batch delete; the // caller owns the eviction and the rescan, because doing either per file is // what made clearing a library take a minute. @@ -737,6 +835,8 @@ inline void begin() { // the browser from the pattern's JS (shipped inside packs) — the device // never streams pixels. (/remote and /api/knob went with it — unused.) server().on("/api/patterns/select", HTTP_GET, handleSelect); + server().on("/api/patterns/pending", HTTP_GET, handlePendingSelect); + server().on("/api/patterns/file", HTTP_GET, handleFile); server().on("/api/patterns", HTTP_GET, handleList); server().on("/api/patterns", HTTP_POST, handleUploadDone, handleUpload); // Raw-body PUT is what the page actually uses: the WebServer's multipart diff --git a/firmware/patternflow/src/mqtt_index.h b/firmware/patternflow/src/mqtt_index.h index 189b1fc..3713f79 100644 --- a/firmware/patternflow/src/mqtt_index.h +++ b/firmware/patternflow/src/mqtt_index.h @@ -1,13 +1,5 @@ // ═══════════════════════════════════════════════════════════ -// PatternFlow - /mqtt role page (served by core_mqtt_http.h) -// Same cream/ink/LED tokens as the other consoles; no external assets. -// -// The broker is compile-time, so the only control here is the role. When -// no broker is configured the page says so and points at the secrets file -// rather than leaving you watching a connect loop fail. -// -// The role picker is Simone Majocchi's (@SimonePDA), from his Patternflow -// fork; restyled here to the device console's palette. +// PatternFlow - /mqtt channel + role page (served by core_mqtt_http.h) // License: MIT // ═══════════════════════════════════════════════════════════ #pragma once @@ -19,7 +11,6 @@ static const char MQTT_INDEX_HTML[] PROGMEM = R"HTML( Patternflow - MQTT

MQTT

-
-

Role

-
+ + + +
+
+

Channel

+ -
- - - +
+ + + + + + +
-

Pick one panel as publisher and another as subscriber to keep them in sync. - Values are retained on the broker, so a subscriber that joins late still catches up. - The choice survives a reboot.

-

Publish to patternflow/message and the text appears - over the running pattern for ten seconds — any connected role, empty payload clears it. - It is a broadcast: every panel subscribed on this prefix shows it. Use your own - prefix if you want messages that only reach your own panels.

+

Timed shows use Ch1–4 (subscribe only). Live is channel 5. Broadcast is the + community sandbox. Switching channel unsubscribes the previous prefix and resubscribes.

+
+ +
+

Role

+
+ + +
+

Broadcast and Live allow Pub or Sub. Ch1–4 are always Subscriber.

Broker

-
@@ -134,22 +159,22 @@ font-family:var(--mono);font-size:11px;letter-spacing:.04em}
-

The password is stored on the device and never sent back to this page — - leave it empty to keep the one already saved.

+

Channel buttons set the prefix. Edit Prefix only for a custom/local broker namespace, then Save.

State
-
+

Last values

Pattern
-
-
Banner
K1
-
K2
-
K3
-
K4
-
+
P1–4
-
@@ -159,47 +184,62 @@ font-family:var(--mono);font-size:11px;letter-spacing:.04em} function $(i){return document.getElementById(i)} function say(t,cls){$('msg').textContent=t;$('msg').className=cls||''} -// The form is filled once, and again only after a save. This page polls -// every two seconds, and a poll that writes into the inputs would erase -// whatever was half-typed — so once the fields are populated the status -// updates leave them alone. var cfgFilled=false; function fillConfig(d){ - $('f-host').value=d.host||''; - $('f-port').value=d.port||1883; - $('f-user').value=d.user||''; - $('f-prefix').value=d.prefix||''; - // Never populated: the device does not send it back. The placeholder says - // which of the two empty-field meanings applies. + $('f-host').value=(d.normalHost!=null?d.normalHost:'')||''; + $('f-port').value=d.normalPort||1883; + $('f-user').value=d.normalUser||''; + $('f-prefix').value=d.normalPrefix||'patternflow'; $('f-pass').value=''; - $('f-pass').placeholder=d.hasPassword?'unchanged':'none set'; + $('f-pass').placeholder=d.normalHasPassword?'unchanged':'none set'; + if($('f-dirhost')) $('f-dirhost').value=d.directorHost||''; cfgFilled=true; } function paint(d){ var prefix=d.prefix||'patternflow'; + var ch=d.channel||'off'; + var mode=d.mode||'normal'; $('st').textContent=d.connected?'connected':(d.state||'offline'); if(!cfgFilled)fillConfig(d); + if($('f-dirhost') && d.directorHost!=null) $('f-dirhost').value=d.directorHost||$('f-dirhost').value; + if($('m-normal')) $('m-normal').className='ch'+(mode==='normal'?' on':''); + if($('m-director')) $('m-director').className='ch'+(mode==='director'?' on':''); + if($('directorbox')) $('directorbox').className=mode==='director'?'':'hidden'; + if($('normalbox')) $('normalbox').className=mode==='director'?'hidden':''; $('state').textContent=d.state||'-'; - // Only red when it is actually trying and failing: "off" and an - // unconfigured build are resting states, not faults. $('state').className=d.connected?'ok': - (d.role==='off'||!d.configured?'':'bad'); - $('unset').style.display=d.configured?'none':'block'; + (d.role==='off'||ch==='off'||!d.configured?'':'bad'); + $('unset').style.display=d.configured||mode==='director'?'none':'block'; $('pat').textContent=d.pattern||'-'; - // Counts down while it is on the panel, so you can tell a banner that just - // arrived from one that is about to go. - $('banner').textContent=d.messageMs>0 - ?(d.message||'(blank)')+' · '+Math.ceil(d.messageMs/1000)+'s' - :'-'; var k=d.knobs||[0,0,0,0]; for(var i=0;i<4;i++)$('k'+(i+1)).textContent=k[i]; - $('topics').textContent=prefix+'/knob/1..4 · '+prefix+'/pattern · '+prefix+'/message'; - $('msgtopic').textContent=prefix+'/message'; - ['off','publisher','subscriber'].forEach(function(r){ + var p=d.params||[0,0,0,0]; + var pa=d.paramActive||[false,false,false,false]; + $('params').textContent=p.map(function(v,i){return (pa[i]?v:'—')}).join(' · '); + var topics=prefix+'/knob/1..4 · '+prefix+'/pattern · '+prefix+'/param/1..4'; + if(ch==='ch1'||ch==='ch2'||ch==='ch3'||ch==='ch4'||ch==='live') + topics+=' · '+prefix+'/snapshot'; + if(mode==='director') + topics+=' · '+prefix+'/query · '+prefix+'/inventory/# · '+prefix+'/select'; + $('topics').textContent=topics; + + ['off','broadcast','ch1','ch2','ch3','ch4','live'].forEach(function(c){ + var el=$('c-'+c); + if(el)el.className='ch'+(ch===c?' on':''); + }); + var forces=!!d.forcesSub || ch==='ch1'||ch==='ch2'||ch==='ch3'||ch==='ch4'; + var showRole=ch!=='off'; + $('rolebox').className=showRole?'':'hidden'; + ['publisher','subscriber'].forEach(function(r){ var el=$('r-'+r); - if(el)el.className='role'+(d.role===r?' on':''); + if(!el)return; + el.disabled=forces && r==='publisher'; + el.className='role'+(d.role===r?' on':''); }); + $('rolenote').textContent=forces + ?'Channels 1–4 are Subscriber only (Show manager publishes).' + :'Broadcast and Live allow Publisher or Subscriber.'; if(d.error)say(d.error,'err'); } @@ -208,18 +248,30 @@ function load(){ .then(paint).catch(function(){say('cannot reach device','err')}); } +function postMqtt(body,okMsg){ + say('switching…'); + fetch('/api/mqtt',{method:'POST', + headers:{'Content-Type':'application/x-www-form-urlencoded'}, + body:body}) + .then(function(r){return r.json()}).then(function(d){ + if(d.ok===false){say(d.error||'failed','err');return} + say(okMsg,'good'); + paint(d); + }).catch(function(){say('no reply from device','err')}); +} + +Array.prototype.forEach.call(document.querySelectorAll('.ch'),function(btn){ + btn.onclick=function(){ + var channel=btn.getAttribute('data-channel'); + postMqtt('channel='+encodeURIComponent(channel),'channel: '+channel); + }; +}); + Array.prototype.forEach.call(document.querySelectorAll('.role'),function(btn){ btn.onclick=function(){ + if(btn.disabled)return; var role=btn.getAttribute('data-role'); - say('switching…'); - fetch('/api/mqtt',{method:'POST', - headers:{'Content-Type':'application/x-www-form-urlencoded'}, - body:'role='+encodeURIComponent(role)}) - .then(function(r){return r.json()}).then(function(d){ - if(d.ok===false){say(d.error||'failed','err');return} - say('role saved: '+role,'good'); - paint(d); - }).catch(function(){say('no reply from device','err')}); + postMqtt('role='+encodeURIComponent(role),'role saved: '+role); }; }); @@ -231,8 +283,6 @@ function cfgSay(t,cls){var e=$('cfgmsg');e.textContent=t;e.className='note'+(cls if(!host){cfgSay('a host is required — or press Forget','err');return} cfgSay('saving…'); $('save').disabled=true; - // The password rides only when something was typed. An empty field means - // "keep what is stored", so sending it would blank a working login. var body='host='+encodeURIComponent(host)+ '&port='+encodeURIComponent($('f-port').value||1883)+ '&user='+encodeURIComponent($('f-user').value.trim())+ @@ -260,6 +310,47 @@ function cfgSay(t,cls){var e=$('cfgmsg');e.textContent=t;e.className='note'+(cls }).catch(function(){cfgSay('no reply from device','err')}); }; +function dirSay(t,cls){var e=$('dirmsg');if(!e)return;e.textContent=t;e.className='note'+(cls?' '+cls:'')} + +$('m-normal').onclick=function(){ + fetch('/api/mqtt/mode',{method:'POST', + headers:{'Content-Type':'application/x-www-form-urlencoded'}, + body:'mode=normal'}) + .then(function(r){return r.json()}).then(function(d){ + if(d.ok===false){say(d.error||'failed','err');return} + say('Normal MQTT','good'); + paint(d); + }).catch(function(){say('no reply from device','err')}); +}; + +$('m-director').onclick=function(){ + fetch('/api/mqtt/mode',{method:'POST', + headers:{'Content-Type':'application/x-www-form-urlencoded'}, + body:'mode=director'}) + .then(function(r){return r.json()}).then(function(d){ + if(d.ok===false){say(d.error||'failed','err');return} + say('Director MQTT — paste the PC IP','good'); + paint(d); + }).catch(function(){say('no reply from device','err')}); +}; + +$('dir').onsubmit=function(ev){ + ev.preventDefault(); + var host=$('f-dirhost').value.trim(); + if(!host){dirSay('paste the Director PC IP','err');return} + dirSay('connecting…'); + $('dirsave').disabled=true; + fetch('/api/mqtt/director',{method:'POST', + headers:{'Content-Type':'application/x-www-form-urlencoded'}, + body:'host='+encodeURIComponent(host)}) + .then(function(r){return r.json()}).then(function(d){ + $('dirsave').disabled=false; + if(d.ok===false){dirSay(d.error||'could not save','err');return} + dirSay('saved — connecting to '+host,'good'); + paint(d); + }).catch(function(){$('dirsave').disabled=false;dirSay('no reply from device','err')}); +}; + load(); setInterval(load,2000); )HTML"; diff --git a/firmware/patternflow/src/patterns_index.h b/firmware/patternflow/src/patterns_index.h index a56e024..8d161c5 100644 --- a/firmware/patternflow/src/patterns_index.h +++ b/firmware/patternflow/src/patterns_index.h @@ -159,6 +159,7 @@ font-family:var(--mono);font-size:11px;letter-spacing:.04em}

Installed

+ @@ -175,7 +176,7 @@ font-family:var(--mono);font-size:11px;letter-spacing:.04em} function $(i){return document.getElementById(i)} var msg=$('msg'),listEl=$('list'),drop=$('drop'),file=$('file'),fs=$('fs'), qEl=$('q'),retryBtn=$('retry'),bulk=$('bulk'),bulkDel=$('bulkDel'),bulkN=$('bulkN'), - selAll=$('selAll'); + selAll=$('selAll'),bulkZip=$('bulkZip'); // Only modules get a checkbox — presets live in firmware.bin and are not // deletable — so these are also the count of what bulk actions can touch. @@ -196,6 +197,8 @@ function updateBulk(){ bulk.style.display=total?'flex':'none'; bulkDel.textContent='Delete selected ('+n+')'; bulkDel.disabled=n===0; + bulkZip.textContent='Download ZIP ('+n+')'; + bulkZip.disabled=n===0; selAll.textContent=(n===total&&total>0)?'Clear selection':'Select all'; bulkN.textContent=total?(n?'presets cannot be deleted':total+' module'+(total===1?'':'s')):''; } @@ -207,6 +210,35 @@ selAll.onclick=function(){ updateBulk(); }; +// Director MQTT select marks modules here. Rev changes when a new list arrives. +var pendingRev=-1; +function applyPendingSlugs(slugs,rev,announce,force){ + if(!force && rev===pendingRev)return; + var revChanged=rev!==pendingRev; + pendingRev=rev; + var set={}; + (slugs||[]).forEach(function(s){set[String(s).toLowerCase()]=1}); + var n=0; + selBoxes().forEach(function(c){ + var on=!!set[String(c.dataset.slug||'').toLowerCase()]; + c.checked=on; + if(on)n++; + }); + updateBulk(); + if(announce!==false && revChanged && (slugs||[]).length){ + var missed=(slugs||[]).length-n; + var msg='Director marked '+n+' module'+(n===1?'':'s')+' for download'; + if(missed>0)msg+=', '+missed+' not on this list'; + say(msg,n?'good':'err'); + } +} +function pollPending(){ + fetch('/api/patterns/pending',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){ + applyPendingSlugs(d.slugs||[], d.rev||0, true); + }).catch(function(){}); +} +setInterval(pollPending,2500); + function say(t,cls){msg.textContent=t;msg.className=cls||''} // A wait with a moving bar and a seconds count. Used for the requests the @@ -268,6 +300,7 @@ function load(){ } listEl.appendChild(li); }); + applyPendingSlugs(d.pending||[], d.pendingRev||0, false, true); updateBulk(); }).catch(function(){say('cannot reach device','err')}); } @@ -408,6 +441,62 @@ bulkDel.onclick=function(){ deleteSlugs(slugs,slugs.length+' module'+(slugs.length===1?'':'s')); }; +function fetchPatternFile(slug,ext){ + return fetch('/api/patterns/file?slug='+encodeURIComponent(slug)+'&ext='+ext,{cache:'no-store'}) + .then(function(r){ + if(r.status===404)return null; + if(!r.ok)throw new Error('could not read '+slug+'.'+ext); + return r.arrayBuffer().then(function(b){return new Uint8Array(b)}); + }); +} + +// Sequential on purpose: this HTTP server is one-connection. Parallel +// fetches pile up the same way a live preview once locked the panel. +bulkZip.onclick=function(){ + var slugs=selectedSlugs(); + if(!slugs.length)return; + busy('packing zip'); + bulkZip.disabled=true; + loadFflate().then(function(){ + var files={}; + var chain=Promise.resolve(); + slugs.forEach(function(slug,i){ + chain=chain.then(function(){ + say('packing zip — '+(i+1)+'/'+slugs.length); + return fetchPatternFile(slug,'pfm').then(function(pfm){ + if(!pfm)throw new Error(slug+'.pfm not on device'); + files[slug+'.pfm']=pfm; + return fetchPatternFile(slug,'json'); + }).then(function(js){ + if(js)files[slug+'.json']=js; + }); + }); + }); + return chain.then(function(){ + if(!window.fflate||typeof fflate.zipSync!=='function') + throw new Error('zip library missing zipSync'); + var zipped=fflate.zipSync(files,{level:6}); + var blob=new Blob([zipped],{type:'application/zip'}); + var a=document.createElement('a'); + a.href=URL.createObjectURL(blob); + a.download='patternflow-pack.zip'; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function(){URL.revokeObjectURL(a.href)},2000); + busyDone(); + bulkZip.disabled=false; + updateBulk(); + say('downloaded '+slugs.length+' module'+(slugs.length===1?'':'s'),'good'); + }); + }).catch(function(e){ + busyDone(); + bulkZip.disabled=false; + updateBulk(); + say((e&&e.message)||'zip failed','err'); + }); +}; + function del(slug,name){ if(!confirm('Delete "'+name+'"?'))return; fetch('/api/patterns?slug='+encodeURIComponent(slug),{method:'DELETE'}) diff --git a/firmware/toolchain/build_module.py b/firmware/toolchain/build_module.py index 0827083..920925a 100644 --- a/firmware/toolchain/build_module.py +++ b/firmware/toolchain/build_module.py @@ -22,6 +22,7 @@ import contextlib import json import os +import re import shutil import subprocess import sys @@ -156,12 +157,33 @@ def build_one(src_dir: Path, gxx: Path, compile_only: bool, abi: Path, obj_dir.mkdir(parents=True, exist_ok=True) obj = obj_dir / "pattern.o" + # Which firmware generation this build targets — NOT a property of the + # pattern. A converted source builds either way (pf_params.h compiles the + # absolute tier out at target 1), so the whole catalogue can be converted + # long before the fleet has updated: builds keep coming out installable, + # and the day the absolute firmware is the norm, PF_TARGET_ABI=2 turns the + # bus on for everything at once with no second migration. + # + # Default 1 because the wrong guess is not symmetric: target 1 on new + # firmware costs a feature nobody has asked for yet, target 2 on old + # firmware is a module that refuses to install. + target_abi = 2 if os.environ.get("PF_TARGET_ABI") == "2" else 1 + source_text = (src_dir / "pattern.cpp").read_text(encoding="utf-8", errors="replace") + source_absolute = bool(re.search(r"\bABSOLUTE_READY\s*=\s*true\b", source_text)) + # A delta-only source gains nothing from the newer host, so it stays at 1 + # and stays installable everywhere regardless of the target. + module_abi = 2 if (source_absolute and target_abi == 2) else 1 + # What this BUILD can do, which is what the device's registry reads — + # a converted source built for the old host is honestly not absolute-ready. + absolute_ready = source_absolute and module_abi == 2 + cmd = [ str(gxx), f"-O{opt}", *CXXFLAGS, f"-DPF_PANEL_W={PANEL_W}", f"-DPF_PANEL_H={PANEL_H}", + f"-DPF_ABI_MODULE_VERSION={module_abi}", f"-I{abi}", f"-I{SKETCH / 'src'}", f"-I{src_dir}", @@ -201,10 +223,13 @@ def build_one(src_dir: Path, gxx: Path, compile_only: bool, abi: Path, meta_src = src_dir / "module.json" meta = json.loads(meta_src.read_text(encoding="utf-8")) if meta_src.is_file() else {} + # The sidecar reports what the descriptor was actually stamped with (see + # module_abi above), so the device's registry scan and the loader agree. meta.update( { "slug": slug, - "abi": 1, + "abi": module_abi, + "absoluteReady": absolute_ready, "panel_w": PANEL_W, "panel_h": PANEL_H, "module": f"{slug}.pfm", diff --git a/firmware/toolchain/make_pack.py b/firmware/toolchain/make_pack.py index 6cdedd5..54e684d 100644 --- a/firmware/toolchain/make_pack.py +++ b/firmware/toolchain/make_pack.py @@ -193,7 +193,9 @@ def main() -> None: "patterns": len(slugs), "authors": authors, "licenses": licences, - "abi": 1, + # The highest descriptor version in the pack — what a firmware must + # accept to load everything here (2 since the absolute-param bus). + "abi": max((int(m[2].get("abi", 1)) for m in modules), default=1), "panel": {"w": 128, "h": 64}, "order": slugs, # slug -> pattern number, for the modules that came from a repo preset. diff --git a/firmware/toolchain/migrate_community_absolute.py b/firmware/toolchain/migrate_community_absolute.py new file mode 100644 index 0000000..62badf5 --- /dev/null +++ b/firmware/toolchain/migrate_community_absolute.py @@ -0,0 +1,471 @@ +""" +One-shot migration: make community pattern headers absolute-ready (PFParams). + + python firmware/toolchain/migrate_community_absolute.py --db path/to/community.db --dry-run + python firmware/toolchain/migrate_community_absolute.py --db path/to/community.db + +Rewrites `patterns.code_cpp` (and `pattern_headers.code_cpp` where that table +exists) the same way the repo presets were converted: delta-accumulation +idioms become PFParams:: calls, a `constexpr bool ABSOLUTE_READY = true;` +lands after KNOB_LABELS, button resets get the absolute-hold guard, and +`core_params.h` joins the includes. + +Two safety rules this deliberately enforces: + + 1. ALL-OR-NOTHING PER PATTERN. A header is written back only when no + knobDeltas access survives outside comments. A partial conversion would + mix absolute and delta channels in one pattern — worse than either pure + form — so anything the regexes cannot fully claim is left byte-identical + and reported as NEED MANUAL. (Upstream's make_absolute_ready.py marks + those ABSOLUTE_READY=true anyway; that flaw is what this script fixes.) + + 2. ORIGINALS ARE DUMPED FIRST. Before any write, every row this run will + touch is copied to a JSON file beside the database + (community-code-cpp-backup-.json). Restoring is a small + script over that file, not a prayer. + +Deck zips need no cache work afterwards: their fingerprint is a digest of +code_cpp, so every converted pattern's packs rebuild on next download. + +The transformation regexes are ported from Simone Majocchi's +toolchain/make_absolute_ready.py (his performance-director branch) — the +same code that converted the repo's 33 presets — trimmed to the idioms that +actually appear in community headers. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sqlite3 +import sys +import time +from pathlib import Path + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +LABELS_RE = re.compile(r"KNOB_LABELS\s*\[\s*4\s*\]\s*=\s*\{(.*?)\}", re.DOTALL) +ABS_READY_RE = re.compile(r"constexpr\s+bool\s+ABSOLUTE_READY\s*=\s*(true|false)\s*;") + + +# ── include + flag ──────────────────────────────────────────────────────── + + +def ensure_core_params_include(text: str) -> str: + if "core_params.h" in text or "pf_params.h" in text: + return text + # Community headers say `#include "src/..."`; repo presets say + # `#include "../src/..."`. Follow whichever style the file already uses. + prefix = "../src" if '#include "../src/' in text else "src" + lines = text.splitlines(keepends=True) + last_inc = -1 + for i, line in enumerate(lines): + if line.lstrip().startswith("#include"): + last_inc = i + if last_inc < 0: + return f'#include "{prefix}/core_params.h"\n' + text + lines.insert(last_inc + 1, f'#include "{prefix}/core_params.h"\n') + return "".join(lines) + + +def ensure_absolute_ready_flag(text: str) -> str: + if ABS_READY_RE.search(text): + return ABS_READY_RE.sub("constexpr bool ABSOLUTE_READY = true;", text, count=1) + m = LABELS_RE.search(text) + if not m: + return text + semi = text.find(";", m.end()) + if semi < 0: + return text + return text[: semi + 1] + "\nconstexpr bool ABSOLUTE_READY = true;" + text[semi + 1 :] + + +# ── delta-idiom rewrites (ported from make_absolute_ready.py) ───────────── + + +def transform_fminmax_assign(text: str) -> str: + var = r"(?P[A-Za-z_][\w.]*(?:\[[0-3]\])?)" + + def apply_line(m: re.Match) -> str: + return ( + f"{m.group('indent')}PFParams::apply(input, {m.group('idx')}, &{m.group('var')}, " + f"{m.group('lo').strip()}, {m.group('hi').strip()}, {m.group('step').strip()});" + ) + + # var += deltas * step; var = fmaxf(lo, fminf(hi, var)); + text = re.sub( + rf"(?P[ \t]*){var}\s*\+=\s*input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^;]+);\s*" + rf"(?P=var)\s*=\s*fmaxf\s*\(\s*(?P[^,]+)\s*,\s*fminf\s*\(\s*(?P[^,]+)\s*,\s*(?P=var)\s*\)\s*\)\s*;", + apply_line, text, flags=re.MULTILINE) + + # var += deltas * step; var = fminf(fmaxf(var, lo), hi); + text = re.sub( + rf"(?P[ \t]*){var}\s*\+=\s*input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^;]+);\s*" + rf"(?P=var)\s*=\s*fminf\s*\(\s*fmaxf\s*\(\s*(?P=var)\s*,\s*(?P[^,]+)\s*\)\s*,\s*(?P[^)]+)\s*\)\s*;", + apply_line, text, flags=re.MULTILINE) + + # var = fminf(fmaxf(var + deltas * step, lo), hi); + text = re.sub( + rf"(?P[ \t]*){var}\s*=\s*fminf\s*\(\s*fmaxf\s*\(\s*(?P=var)\s*\+\s*" + rf"input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^,]+)\s*,\s*(?P[^,]+)\s*\)\s*,\s*(?P[^)]+)\s*\)\s*;", + apply_line, text, flags=re.MULTILINE) + + return text + + +def transform_if_delta_blocks(text: str) -> str: + """if (input.knobDeltas[i] != 0) { var += ...; optional clamps }""" + pat = re.compile( + r"(?P[ \t]*)if\s*\(\s*input\.knobDeltas\[(?P[0-3])\]\s*!=\s*0\s*\)\s*\{\s*" + r"(?P[A-Za-z_][\w.]*)\s*\+=\s*input\.knobDeltas\[(?P=idx)\]\s*\*\s*(?P[^;]+);\s*" + r"(?:if\s*\(\s*(?P=var)\s*<\s*(?P[^)]+)\)\s*(?P=var)\s*=\s*(?P=lo)\s*;\s*)?" + r"(?:if\s*\(\s*(?P=var)\s*>\s*(?P[^)]+)\)\s*(?P=var)\s*=\s*(?P=hi)\s*;\s*)?" + r"\}", + re.MULTILINE, + ) + + def repl(m: re.Match) -> str: + lo = m.group("lo") + hi = m.group("hi") + if lo and hi: + return ( + f"{m.group('indent')}PFParams::apply(input, {m.group('idx')}, &{m.group('var')}, " + f"{lo.strip()}, {hi.strip()}, {m.group('step').strip()});" + ) + return ( + f"{m.group('indent')}PFParams::apply(input, {m.group('idx')}, &{m.group('var')}, " + f"0.0f, 1.0f, {m.group('step').strip()});" + ) + + return pat.sub(repl, text) + + +def transform_plain_delta_clamps(text: str) -> str: + """var += deltas * step; followed by the common clamp shapes.""" + # += then two if-clamps + text = re.sub( + r"(?P[ \t]*)(?P[A-Za-z_][\w.]*)\s*\+=\s*input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^;]+);\s*" + r"if\s*\(\s*(?P=var)\s*<\s*(?P[^)]+)\)\s*(?P=var)\s*=\s*(?P=lo)\s*;\s*" + r"if\s*\(\s*(?P=var)\s*>\s*(?P[^)]+)\)\s*(?P=var)\s*=\s*(?P=hi)\s*;", + lambda m: ( + f"{m.group('indent')}PFParams::apply(input, {m.group('idx')}, &{m.group('var')}, " + f"{m.group('lo').strip()}, {m.group('hi').strip()}, {m.group('step').strip()});" + ), + text, flags=re.MULTILINE) + + # var = constrain(var + deltas * step, lo, hi); + text = re.sub( + r"(?P[ \t]*)(?P[A-Za-z_][\w.]*)\s*=\s*constrain\s*\(\s*(?P=var)\s*\+\s*input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^,]+)\s*,\s*(?P[^,]+)\s*,\s*(?P[^)]+)\)\s*;", + lambda m: ( + f"{m.group('indent')}PFParams::apply(input, {m.group('idx')}, &{m.group('var')}, " + f"{m.group('lo').strip()}, {m.group('hi').strip()}, {m.group('step').strip()});" + ), + text) + + # var = clampf(var + deltas * step, lo, hi); + text = re.sub( + r"(?P[ \t]*)(?P[A-Za-z_][\w.]*)\s*=\s*clampf\s*\(\s*(?P=var)\s*\+\s*" + r"input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^,]+)\s*,\s*(?P[^,]+)\s*,\s*(?P[^)]+)\)\s*;", + lambda m: ( + f"{m.group('indent')}PFParams::apply(input, {m.group('idx')}, &{m.group('var')}, " + f"{m.group('lo').strip()}, {m.group('hi').strip()}, {m.group('step').strip()});" + ), + text) + + return text + + +def transform_one_sided_max(text: str) -> str: + """var = max(lo, var + deltas * step); — unbounded above → soft 10.0f cap. + + Same call upstream's converter made for the repo presets, so a community + copy of 0510 ends up byte-identical to the converted preset. + """ + return re.sub( + r"(?P[ \t]*)(?P[A-Za-z_][\w.]*)\s*=\s*max\s*\(\s*(?P[^,]+)\s*,\s*" + r"(?P=var)\s*\+\s*input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^)]+)\)\s*;", + lambda m: ( + f"{m.group('indent')}PFParams::apply(input, {m.group('idx')}, &{m.group('var')}, " + f"{m.group('lo').strip()}, 10.0f, {m.group('step').strip()});" + ), + text) + + +def transform_grouped_deltas(text: str) -> str: + """+= lines whose clamp/wrap sits later in the function (ported from + make_absolute_ready.py's windowed transform, minus its dead branches).""" + add_re = re.compile( + r"(?P[ \t]*)(?P[A-Za-z_][\w.]*(?:\[[0-3]\])?)\s*\+=\s*input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^;]+);" + ) + adds = list(add_re.finditer(text)) + if not adds: + return text + + replacements: list[tuple[int, int, str]] = [] + consumed: list[tuple[int, int]] = [] + + def overlaps(start: int, end: int) -> bool: + return any(not (end <= a or start >= b) for a, b in consumed) + + for m in adds: + var = m.group("var") + idx = m.group("idx") + step = m.group("step").strip() + indent = m.group("indent") + window = text[m.end() : m.end() + 900] + + wrap_lo = re.search( + rf"if\s*\(\s*{re.escape(var)}\s*<\s*0(?:\.0f)?\s*\)\s*{re.escape(var)}\s*\+=\s*1(?:\.0f)?\s*;", window) + wrap_hi = re.search( + rf"if\s*\(\s*{re.escape(var)}\s*>=?\s*1(?:\.0f)?\s*\)\s*{re.escape(var)}\s*-=\s*1(?:\.0f)?\s*;", window) + fmod_line = re.search(rf"{re.escape(var)}\s*=\s*fmodf\([^;]+;", window) + while_wraps = list(re.finditer( + rf"while\s*\(\s*{re.escape(var)}\s*[<>]=?\s*[^)]+\)\s*{re.escape(var)}\s*[+-]=\s*[^;]+;", window)) + lo_m = re.search( + rf"if\s*\(\s*{re.escape(var)}\s*<\s*(?P[^)]+)\)\s*{re.escape(var)}\s*=\s*(?P=lo)\s*;", window) + hi_m = re.search( + rf"if\s*\(\s*{re.escape(var)}\s*>\s*(?P[^)]+)\)\s*{re.escape(var)}\s*=\s*(?P=hi)\s*;", window) + fm_m = re.search( + rf"{re.escape(var)}\s*=\s*fmaxf\s*\(\s*(?P[^,]+)\s*,\s*fminf\s*\(\s*(?P[^,]+)\s*,\s*{re.escape(var)}\s*\)\s*\)\s*;", window) + fm_m2 = re.search( + rf"{re.escape(var)}\s*=\s*fminf\s*\(\s*fmaxf\s*\(\s*{re.escape(var)}\s*,\s*(?P[^,]+)\s*\)\s*,\s*(?P[^)]+)\s*\)\s*;", window) + fm_m3 = re.search( + rf"{re.escape(var)}\s*=\s*fminf\s*\(\s*(?P[^,]+)\s*,\s*fmaxf\s*\(\s*(?P[^,]+)\s*,\s*{re.escape(var)}\s*\)\s*\)\s*;", window) + con_m = re.search( + rf"{re.escape(var)}\s*=\s*constrain\s*\(\s*{re.escape(var)}\s*,\s*(?P[^,]+)\s*,\s*(?P[^)]+)\)\s*;", window) + + clamp = fm_m or fm_m2 or fm_m3 or con_m + if clamp: + new = (f"{indent}PFParams::apply(input, {idx}, &{var}, " + f"{clamp.group('lo').strip()}, {clamp.group('hi').strip()}, {step});") + elif wrap_lo or wrap_hi or fmod_line or while_wraps: + new = f"{indent}PFParams::applyUnit(input, {idx}, &{var}, {step});" + elif lo_m and hi_m: + new = (f"{indent}PFParams::apply(input, {idx}, &{var}, " + f"{lo_m.group('lo').strip()}, {hi_m.group('hi').strip()}, {step});") + else: + continue + + if overlaps(m.start(), m.end()): + continue + replacements.append((m.start(), m.end(), new)) + consumed.append((m.start(), m.end())) + + # Blank out the distant clamp/wrap lines this apply subsumes. + followers: list[re.Match] = [] + if clamp: + followers.append(clamp) + if lo_m and hi_m and not clamp: + followers.extend([lo_m, hi_m]) + for wm in (wrap_lo, wrap_hi): + if wm: + followers.append(wm) + if fmod_line and not clamp: + followers.append(fmod_line) + followers.extend(while_wraps) + for fm in followers: + cs = m.end() + fm.start() + ce = m.end() + fm.end() + if overlaps(cs, ce): + continue + replacements.append((cs, ce, "")) + consumed.append((cs, ce)) + + for start, end, new in sorted(replacements, key=lambda x: x[0], reverse=True): + text = text[:start] + new + text[end:] + return text + + +def transform_hue_wraps(text: str) -> str: + """The three spellings of `hue += delta; wrap into 0..1` → applyUnit.""" + # += step; if (<0) += 1; fmodf(...) + text = re.sub( + r"(?P[ \t]*)(?P[A-Za-z_][\w.]*)\s*\+=\s*input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^;]+);\s*" + r"if\s*\(\s*(?P=var)\s*<\s*0(?:\.0f)?\s*\)\s*(?P=var)\s*\+=\s*1(?:\.0f)?\s*;\s*" + r"(?P=var)\s*=\s*fmodf\s*\([^;]+;", + lambda m: ( + f"{m.group('indent')}PFParams::applyUnit(input, {m.group('idx')}, &{m.group('var')}, " + f"{m.group('step').strip()});" + ), + text, flags=re.MULTILINE) + + # var = fmodf(var + deltas * step, 1.0f); [optional if (<0) += 1] + # Paren salad tolerated: the generator spelling is + # fmodf((float)((var + deltas * step)), (float)(1.0f)) + text = re.sub( + r"(?P[ \t]*)(?P[A-Za-z_][\w.]*)\s*=\s*fmodf\s*\(\s*[\s(]*(?:\(float\)\s*)?[\s(]*(?P=var)\s*\+\s*" + r"input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^,()]+)[\s)]*,\s*(?:\(float\)\s*)?[\s(]*1(?:\.0f)?[\s)]*\)\s*;\s*" + r"(?:if\s*\(\s*(?P=var)\s*<\s*0(?:\.0f)?\s*\)\s*(?P=var)\s*\+=\s*1(?:\.0f)?\s*;\s*)?", + lambda m: ( + f"{m.group('indent')}PFParams::applyUnit(input, {m.group('idx')}, &{m.group('var')}, " + f"{m.group('step').strip()});\n" + ), + text, flags=re.MULTILINE) + + # += step; wrap via two ifs (+= span / -= span) over 0..1 + text = re.sub( + r"(?P[ \t]*)(?P[A-Za-z_][\w.]*)\s*\+=\s*input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^;]+);\s*" + r"if\s*\(\s*(?P=var)\s*<\s*0(?:\.0f)?\s*\)\s*(?P=var)\s*\+=\s*1(?:\.0f)?\s*;\s*" + r"if\s*\(\s*(?P=var)\s*>=?\s*1(?:\.0f)?\s*\)\s*(?P=var)\s*-=\s*1(?:\.0f)?\s*;", + lambda m: ( + f"{m.group('indent')}PFParams::applyUnit(input, {m.group('idx')}, &{m.group('var')}, " + f"{m.group('step').strip()});" + ), + text, flags=re.MULTILINE) + + return text + + +def guard_btn_resets(text: str) -> str: + return re.sub( + r"if\s*\(\s*input\.btnPressed\[([0-3])\]\s*\)\s+(?!&&)", + r"if (input.btnPressed[\1] && !input.paramAbsoluteActive[\1] && !input.knobAudioActive[\1]) ", + text, + ) + + +def still_has_deltas(text: str) -> bool: + body = re.sub(r"//.*?$", "", text, flags=re.MULTILINE) + body = re.sub(r"/\*.*?\*/", "", body, flags=re.DOTALL) + return "knobDeltas" in body + + +def convert(code: str) -> tuple[str, bool]: + """Return (converted, fully_converted). Original returned when partial.""" + text = transform_fminmax_assign(code) + text = transform_hue_wraps(text) + text = transform_if_delta_blocks(text) + text = transform_plain_delta_clamps(text) + text = transform_one_sided_max(text) + text = transform_grouped_deltas(text) + if still_has_deltas(text): + return code, False + text = ensure_core_params_include(text) + text = ensure_absolute_ready_flag(text) + text = guard_btn_resets(text) + return text, True + + +# ── database walk ───────────────────────────────────────────────────────── + + +# ── repo-twin replacement ───────────────────────────────────────────────── +# +# The community DB was seeded from the repo's presets, so many rows are +# byte-identical copies of a preset's pre-conversion source (verified against +# git HEAD for Origin / Wave Saw / 0513). Those need no regex at all: when a +# row's code matches a preset's PRE-conversion source exactly (by hash), the +# row takes the repo's hand-checked converted file wholesale. Hashes live in +# preset_twin_hashes.json beside this script (generated at conversion time by +# gen_twin_hashes.py); the converted sources come from --presets-dir. + + +def norm_code(s: str) -> str: + return s.replace("\r\n", "\n").strip() + + +def load_twins(presets_dir: Path) -> dict[str, str]: + """sha1(normalized old source) → converted source text.""" + hashes_path = Path(__file__).with_name("preset_twin_hashes.json") + if not hashes_path.is_file() or not presets_dir.is_dir(): + return {} + mapping = json.loads(hashes_path.read_text(encoding="utf-8")) + out: dict[str, str] = {} + for old_sha, filename in mapping.items(): + src = presets_dir / filename + if src.is_file(): + out[old_sha] = src.read_text(encoding="utf-8") + return out + + +def twin_lookup(twins: dict[str, str], code: str) -> str | None: + sha = hashlib.sha1(norm_code(code).encode("utf-8")).hexdigest() + return twins.get(sha) + + +def table_exists(db: sqlite3.Connection, name: str) -> bool: + row = db.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,) + ).fetchone() + return row is not None + + +def migrate(db_path: Path, dry: bool, presets_dir: Path) -> int: + db = sqlite3.connect(db_path) + targets = [("patterns", "id")] + if table_exists(db, "pattern_headers"): + targets.append(("pattern_headers", "id")) + + twins = load_twins(presets_dir) + backup: dict[str, dict[str, str]] = {} + planned: list[tuple[str, str, str]] = [] # table, id, new_code + manual: list[tuple[str, str]] = [] + already: int = 0 + twinned: int = 0 + + for table, key in targets: + rows = db.execute( + f"SELECT {key}, code_cpp FROM {table} WHERE code_cpp IS NOT NULL AND code_cpp != ''" + ).fetchall() + for row_id, code in rows: + if "PFParams::" in code: + already += 1 + continue + if "knobDeltas" not in code: + # No knob handling at all — still absolute-safe by + # definition, but flagging it ready would promise Director + # response it cannot give. Leave it alone. + continue + twin = twin_lookup(twins, code) + if twin is not None: + backup.setdefault(table, {})[str(row_id)] = code + planned.append((table, str(row_id), twin)) + twinned += 1 + continue + new_code, ok = convert(code) + if not ok: + manual.append((table, str(row_id))) + continue + backup.setdefault(table, {})[str(row_id)] = code + planned.append((table, str(row_id), new_code)) + + print(f"convertible: {len(planned)} (of which repo twins: {twinned}) " + f"already PFParams: {already} NEED MANUAL: {len(manual)}") + for table, row_id in manual: + print(f" NEED MANUAL: {table}.{row_id}") + + if dry or not planned: + db.close() + return 0 + + stamp = time.strftime("%Y%m%d-%H%M%S") + backup_path = db_path.with_name(f"community-code-cpp-backup-{stamp}.json") + backup_path.write_text(json.dumps(backup, indent=1), encoding="utf-8") + print(f"originals saved: {backup_path}") + + for table, row_id, new_code in planned: + db.execute(f"UPDATE {table} SET code_cpp = ? WHERE id = ?", (new_code, row_id)) + db.commit() + db.close() + print(f"updated {len(planned)} row(s)") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--db", type=Path, required=True, help="community.db path") + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--presets-dir", type=Path, + default=Path(__file__).resolve().parents[1] / "patternflow" / "presets", + help="Converted repo presets, used for repo-twin rows") + args = ap.parse_args() + if not args.db.is_file(): + sys.exit(f"no database at {args.db}") + return migrate(args.db, args.dry_run, args.presets_dir) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/firmware/toolchain/preset_twin_hashes.json b/firmware/toolchain/preset_twin_hashes.json new file mode 100644 index 0000000..ada0ccf --- /dev/null +++ b/firmware/toolchain/preset_twin_hashes.json @@ -0,0 +1,36 @@ +{ + "4044b37cbce604ecbecc8a9c5356057cc2e8fcd4": "preset_0510.h", + "ff7ed307186d61325b33c392d9f155ff83769488": "preset_0511.h", + "221be07a1c1f9cad7b4380798bbc91b404b8bee8": "preset_0512.h", + "081be119ef39eb10caa26507a886d20eeedde19c": "preset_0513.h", + "48223896919982a3052f2862b49b48e9befaca21": "preset_0514.h", + "80dfeac93dc60b7e386be08e01de3ed05b369538": "preset_0515.h", + "60f0a3d7b69843c66bdbba3350aff8431a67e088": "preset_0515_3.h", + "ec1d86f67497d9063e8f76cb45ebebcbd88b4b83": "preset_0515_4.h", + "371acfbe502189cfbd63314222858629b470877e": "preset_0518.h", + "e065bc66cc42ebe0081a7c91e5c7d91812fa8da1": "preset_0519_1.h", + "7141e3f5464e4165f3eff9651753736b5faff318": "preset_0520.h", + "61f976873096c6a932d1858a4705cbf0166e4e81": "preset_0521.h", + "908b4fc38012f80b7a46c216de7513df6dad26d3": "preset_0522.h", + "a71d33135e5aca9370e851fbaa20afbb8112756d": "preset_0527.h", + "9e4e427db19c7004b8269dcfd6f073b0e93ffc46": "preset_0528.h", + "89e3aa4dd1ae79cad0e3d048f6bb6e375f4d7e40": "preset_0531.h", + "e70692af255cc9ecb9b6bd12f2e1121b703c9506": "preset_0601.h", + "17e30e3bfb949d1a685b079dee1ba06394c47b44": "preset_0602.h", + "45a5cafb4c180f4beeb5a331c863a4b6a2761df1": "preset_0628.h", + "e470c9ee131f1bae629df4499f1c2c4564817386": "preset_0629.h", + "9a5ee231aecc6639ce47cf8acecc7f7033c213cf": "preset_0629_2.h", + "476a886599e5fc8f44100b58c764783e5b3ecb99": "preset_0701.h", + "fd84297e376f6cb594f20b20d79dbdc407f0de3f": "preset_0707.h", + "fb9d9adf5a4acc1a048b3b5e30c30e247f0468ed": "preset_0710.h", + "205b23411ac787e2b5c3f63e8e53361f135f424d": "preset_0712.h", + "d900480bff2519d47c182bb4e518de99e09ca600": "preset_0712_2.h", + "6d65bff5221dbfe7251e9dfb50fc6a5d2c6605fa": "preset_0713.h", + "ebab89c84f0a20990a8d9a90c34f54c66e348ee0": "preset_0715.h", + "9d18183276d809582a07a1ff294ae4f7fa054144": "preset_0716.h", + "362472fc97b2864131c3e03ce0eb3b5407b7342e": "preset_0718.h", + "fcba8ec903854b07803c54db537fffcc3bc8bf09": "preset_0719.h", + "ddab4fdf0f9819cd66552a9bd8ba3022815b34c8": "preset_a_big_hit.h", + "cafd7b3540684d3a1fca7ea044c7b14a94d5d1df": "preset_origin.h", + "3c6de5e80d326ced424f16812b5340981ecb250c": "preset_wave_saw.h" +} diff --git a/web/drizzle/0020_deck-performance.sql b/web/drizzle/0020_deck-performance.sql new file mode 100644 index 0000000..6e4a9ef --- /dev/null +++ b/web/drizzle/0020_deck-performance.sql @@ -0,0 +1 @@ +ALTER TABLE `decks` ADD `performance_json` text; diff --git a/web/drizzle/0021_pattern-performances.sql b/web/drizzle/0021_pattern-performances.sql new file mode 100644 index 0000000..5a377f7 --- /dev/null +++ b/web/drizzle/0021_pattern-performances.sql @@ -0,0 +1,12 @@ +CREATE TABLE `pattern_performances` ( + `id` text PRIMARY KEY NOT NULL, + `pattern_id` text NOT NULL, + `user_id` text NOT NULL, + `performance_json` text NOT NULL, + `note` text, + `created_at` integer NOT NULL, + FOREIGN KEY (`pattern_id`) REFERENCES `patterns`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +);--> statement-breakpoint +CREATE INDEX `pattern_performances_pattern_created_idx` ON `pattern_performances` (`pattern_id`,`created_at`);--> statement-breakpoint +ALTER TABLE `patterns` ADD `pinned_performance_id` text; diff --git a/web/drizzle/meta/_journal.json b/web/drizzle/meta/_journal.json index 167e12c..af5ad77 100644 --- a/web/drizzle/meta/_journal.json +++ b/web/drizzle/meta/_journal.json @@ -1,146 +1,160 @@ { - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1784685898155, - "tag": "0000_community-init", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1784708031751, - "tag": "0001_add-likes-and-cpp", - "breakpoints": true - }, - { - "idx": 2, - "version": "6", - "when": 1784710683324, - "tag": "0002_add-made-on", - "breakpoints": true - }, - { - "idx": 3, - "version": "6", - "when": 1784949672023, - "tag": "0003_add-board", - "breakpoints": true - }, - { - "idx": 4, - "version": "6", - "when": 1784987368285, - "tag": "0004_add-builds", - "breakpoints": true - }, - { - "idx": 5, - "version": "6", - "when": 1785135019087, - "tag": "0005_add-build-format", - "breakpoints": true - }, - { - "idx": 6, - "version": "6", - "when": 1785317646720, - "tag": "0006_add-reports", - "breakpoints": true - }, - { - "idx": 7, - "version": "6", - "when": 1785320716351, - "tag": "0007_add-made-how", - "breakpoints": true - }, - { - "idx": 8, - "version": "6", - "when": 1785321984220, - "tag": "0008_add-comment-edited", - "breakpoints": true - }, - { - "idx": 9, - "version": "6", - "when": 1785535849348, - "tag": "0009_add-visibility-and-decks", - "breakpoints": true - }, - { - "idx": 10, - "version": "6", - "when": 1785537782506, - "tag": "0010_add-notifications", - "breakpoints": true - }, - { - "idx": 11, - "version": "6", - "when": 1785648191733, - "tag": "0011_add-pattern-ports", - "breakpoints": true - }, - { - "idx": 12, - "version": "6", - "when": 1785649158014, - "tag": "0012_add-post-notice", - "breakpoints": true - }, - { - "idx": 13, - "version": "6", - "when": 1785840098851, - "tag": "0013_add-map", - "breakpoints": true - }, - { - "idx": 14, - "version": "6", - "when": 1785878219325, - "tag": "0014_merge-unlisted", - "breakpoints": true - }, - { - "idx": 15, - "version": "6", - "when": 1785878534343, - "tag": "0015_add-featured", - "breakpoints": true - }, - { - "idx": 16, - "version": "6", - "when": 1786338000000, - "tag": "0016_add-presence", - "breakpoints": true - }, - { - "idx": 17, - "version": "6", - "when": 1786830000000, - "tag": "0017_add-atlas-pins", - "breakpoints": true - }, - { - "idx": 18, - "version": "6", - "when": 1786833000000, - "tag": "0018_atlas-pin-entry", - "breakpoints": true - }, - { - "idx": 19, - "version": "6", - "when": 1786836000000, - "tag": "0019_deck-zip", - "breakpoints": true - } - ] -} + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1784685898155, + "tag": "0000_community-init", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1784708031751, + "tag": "0001_add-likes-and-cpp", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1784710683324, + "tag": "0002_add-made-on", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1784949672023, + "tag": "0003_add-board", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1784987368285, + "tag": "0004_add-builds", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1785135019087, + "tag": "0005_add-build-format", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1785317646720, + "tag": "0006_add-reports", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1785320716351, + "tag": "0007_add-made-how", + "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1785321984220, + "tag": "0008_add-comment-edited", + "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1785535849348, + "tag": "0009_add-visibility-and-decks", + "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1785537782506, + "tag": "0010_add-notifications", + "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1785648191733, + "tag": "0011_add-pattern-ports", + "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1785649158014, + "tag": "0012_add-post-notice", + "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1785840098851, + "tag": "0013_add-map", + "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1785878219325, + "tag": "0014_merge-unlisted", + "breakpoints": true + }, + { + "idx": 15, + "version": "6", + "when": 1785878534343, + "tag": "0015_add-featured", + "breakpoints": true + }, + { + "idx": 16, + "version": "6", + "when": 1786338000000, + "tag": "0016_add-presence", + "breakpoints": true + }, + { + "idx": 17, + "version": "6", + "when": 1786830000000, + "tag": "0017_add-atlas-pins", + "breakpoints": true + }, + { + "idx": 18, + "version": "6", + "when": 1786833000000, + "tag": "0018_atlas-pin-entry", + "breakpoints": true + }, + { + "idx": 19, + "version": "6", + "when": 1786836000000, + "tag": "0019_deck-zip", + "breakpoints": true + }, + { + "idx": 20, + "version": "6", + "when": 1786840000000, + "tag": "0020_deck-performance", + "breakpoints": true + }, + { + "idx": 21, + "version": "6", + "when": 1786860000000, + "tag": "0021_pattern-performances", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/web/package.json b/web/package.json index 56315a0..bdfc8ce 100644 --- a/web/package.json +++ b/web/package.json @@ -22,7 +22,8 @@ "check:ports": "tsx scripts/ports-smoke.ts", "seed:map": "tsx scripts/seed-territories.ts", "check:workshop": "tsx scripts/workshop-smoke.ts", - "check:sandbox": "tsx scripts/sandbox-smoke.ts" + "check:sandbox": "tsx scripts/sandbox-smoke.ts", + "check:performance": "tsx scripts/performance-smoke.ts" }, "dependencies": { "@chenglou/pretext": "^0.0.6", diff --git a/web/public/packs/basics.json b/web/public/packs/basics.json index 4ef9d92..b893739 100644 --- a/web/public/packs/basics.json +++ b/web/public/packs/basics.json @@ -83,5 +83,5 @@ "magvortex": 719.0, "a_big_hit": 9999.0 }, - "bytes": 115598 + "bytes": 121705 } diff --git a/web/public/packs/basics.zip b/web/public/packs/basics.zip index 3a98bc7..775b2e4 100644 Binary files a/web/public/packs/basics.zip and b/web/public/packs/basics.zip differ diff --git a/web/scripts/deck-pack-smoke.ts b/web/scripts/deck-pack-smoke.ts index ff4ed2a..a03f9a1 100644 --- a/web/scripts/deck-pack-smoke.ts +++ b/web/scripts/deck-pack-smoke.ts @@ -12,9 +12,18 @@ * The CATALOG is the running order the device reads. Written wrong — extra * blanks, missing trailing newline, wrong order — and the device falls back * to sorting alphabetically, silently discarding the arrangement. + * + * The PERFORMANCE decoration runs on every download of a deck that has one, + * and it rewrites the archive — so a mistake there breaks the pack itself, + * not just the performance. It must add both forms, leave the modules alone, + * and refuse to fail loudly: a broken attachment may cost its own file and + * nothing else. */ -import { fingerprintDeck } from "../src/lib/community/deckZip"; +import { unzipSync, zipSync } from "fflate"; + +import { decoratePackWithPerformance, fingerprintDeck } from "../src/lib/community/deckZip"; +import { decodePfst } from "../src/lib/community/performance"; let failures = 0; function check(label: string, actual: unknown, expected: unknown) { @@ -109,5 +118,69 @@ check("comments are '#'-prefixed", lines.slice(0, 2).every((l) => l.startsWith(" // likely to produce a stray empty line. check("a one-module pack is well formed", buildCatalog(["origin"]).split("\n").filter((l) => l.trim() && !l.startsWith("#")), ["origin"]); +// ── performance decoration ─────────────────────────────────────────────── +console.log("\nperformance rides the pack"); + +const basePack = zipSync({ + "wave_saw.pfm": new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 1, 2, 3, 4]), + "wave_saw.json": new TextEncoder().encode('{"slug":"wave_saw","abi":1}'), + "catalog.txt": new TextEncoder().encode("wave_saw\n"), +}); + +const performanceJson = JSON.stringify({ + version: 1, + id: "sunset-set", + title: "Sunset Set", + length: 24, + loop: true, + timeline: [ + { t: 0, pattern: "Wave Saw" }, + { t: 0, param: [200, 500, 500, 800] }, + { t: 12, param: [800, 500, 200, 100] }, + { t: 20, message: "encore" }, + ], +}); + +const decorated = unzipSync(decoratePackWithPerformance(basePack, performanceJson)); +const names = Object.keys(decorated).sort(); + +check("the modules survive untouched", names.includes("wave_saw.pfm"), true); +check("so does the running order", names.includes("catalog.txt"), true); +check("the editable source is added", names.includes("performance.json"), true); +check("the packed table is added, named from the id", names.includes("sunset_set.pfs"), true); +check( + "performance.json is stored verbatim", + new TextDecoder().decode(decorated["performance.json"]), + performanceJson, +); + +// The .pfs is what the panel plays, so it has to be a table, not bytes that +// merely exist — decode it back and check the timeline survived the trip. +const table = decodePfst(decorated["sunset_set.pfs"]); +check("the .pfs decodes to the same show", table.title, "Sunset Set"); +check("its cues are all there", table.timeline.length, 4); +check("its pattern cue points at the pattern", table.timeline[0].pattern, "Wave Saw"); +check("loop survives", table.loop, true); +check("a sparse param patch survives", table.timeline[2].param, [800, 500, 200, 100]); + +// A deck with no performance must get byte-identical bytes back, or every +// download of every plain deck pays for a pointless repack. +check( + "no performance means the pack is not touched at all", + decoratePackWithPerformance(basePack, null) === basePack, + true, +); + +// Broken attachments are the case that must not cascade: the pack is what +// people came for. +for (const [label, bad] of [ + ["not JSON", "{{{"], + ["no timeline", '{"version":1,"title":"Empty"}'], +] as const) { + const out = decoratePackWithPerformance(basePack, bad); + const entries = Object.keys(unzipSync(out)); + check(`a performance that is ${label} still leaves an installable pack`, entries.includes("wave_saw.pfm"), true); +} + console.log(failures ? `\n${failures} FAILED\n` : "\nall passed\n"); process.exit(failures ? 1 : 0); diff --git a/web/scripts/module-build-smoke.ts b/web/scripts/module-build-smoke.ts index f86635c..8f75de4 100644 --- a/web/scripts/module-build-smoke.ts +++ b/web/scripts/module-build-smoke.ts @@ -88,7 +88,11 @@ async function main() { `got ${JSON.stringify(sidecar.name)}`); check("sidecar knob labels", Array.isArray(sidecar.knobs) && sidecar.knobs.length === 4); check("sidecar panel size", sidecar.panel_w === 128 && sidecar.panel_h === 64); - check("sidecar ABI version", sidecar.abi === 1); + // This smoke pattern integrates deltas by hand, so it neither claims to be + // absolute-ready nor needs the newer host: it must stamp ABI 1 and stay + // installable on pre-absolute firmware. A converted source flips both. + check("sidecar absoluteReady flag", sidecar.absoluteReady === false); + check("delta-only pattern stamps ABI 1", sidecar.abi === 1); console.log("\nrejects a malformed header"); const bad = await runModuleBuild([{ code: "not a pattern", label: "bad.h" }], { artifactDir }); diff --git a/web/scripts/performance-smoke.ts b/web/scripts/performance-smoke.ts new file mode 100644 index 0000000..9bff740 --- /dev/null +++ b/web/scripts/performance-smoke.ts @@ -0,0 +1,154 @@ +// Smoke test for the performance library: normalize/validate a Director-shaped +// JSON, encode PFST v1, and assert the byte layout the device's core_show.h +// reads. The encoder was verified byte-identical against the Director PWA's +// own .pfs saves (its four demos) when it landed; this keeps the structural +// contract pinned without needing those files. Run: +// npx tsx scripts/performance-smoke.ts + +import fs from "node:fs"; +import path from "node:path"; + +import { + PFST_HEADER_BYTES, + PFST_CUE_BYTES, + decodePfst, + encodePfst, + normalizePerformance, + pfsFilename, + serializePerformance, + validatePerformance, +} from "../src/lib/community/performance"; + +let failed = 0; +function check(label: string, ok: boolean) { + console.log(` ${ok ? "ok " : "FAIL"} ${label}`); + if (!ok) failed++; +} + +console.log("\nnormalize + validate"); +const raw = JSON.stringify({ + version: 1, + id: "smoke-set", + title: "Smoke Set", + length: 30, + loop: true, + timeline: [ + { t: 5, param: { "2": 800 } }, // sparse, 1-based object form + { t: 0, pattern: "Origin" }, + { t: 0, param: [200, 500, 500, 900] }, + { t: 12.4, message: "encore" }, // quantizes to 12 + { t: 20, message: "" }, // empty clears the banner + { t: 3, param: [2000, -5, null, null] }, // clamps to 1000 / 0 + { bogus: true }, // no usable field — dropped + ], +}); +const verdict = validatePerformance(raw); +check("validates", verdict.ok); +if (!verdict.ok) { + console.log(` (${verdict.error})`); + process.exit(1); +} +const perf = verdict.perf; +check("cues sorted by time, pattern before param at t=0", + perf.timeline[0].pattern === "Origin" && perf.timeline[1].param?.[0] === 200); +check("bogus cue dropped", perf.timeline.length === 6); +check("param clamps to 0..1000", + perf.timeline[2].param?.[0] === 1000 && perf.timeline[2].param?.[1] === 0); +check("time quantized to whole seconds", + perf.timeline.some((cue) => cue.t === 12 && cue.message === "encore")); +check("filename slug", pfsFilename(perf) === "smoke_set.pfs"); + +console.log("\nPFST bytes (the table core_show.h walks)"); +const bytes = encodePfst(perf); +const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); +check("magic PFST", bytes[0] === 0x50 && bytes[1] === 0x46 && bytes[2] === 0x53 && bytes[3] === 0x54); +check("version 1", bytes[4] === 1); +check("loop flag set", bytes[5] === 1); +check("length u16 = 30 (authoring length wins over last cue)", view.getUint16(6, true) === 30); +check("cue count u16 = 6", view.getUint16(8, true) === 6); +const poolBytes = view.getUint16(10, true); +check("total size = header + pool + cues", + bytes.length === PFST_HEADER_BYTES + poolBytes + 6 * PFST_CUE_BYTES); +const title = new TextDecoder().decode(bytes.slice(12, 12 + 9)); +check("title NUL-padded in header", title === "Smoke Set" && bytes[12 + 9] === 0); +// First cue: t=0, pattern "Origin" +const cue0 = PFST_HEADER_BYTES + poolBytes; +check("cue0 t=0 with PATTERN flag", view.getUint16(cue0, true) === 0 && (bytes[cue0 + 2] & 1) === 1); +const patternOff = view.getUint16(cue0 + 4, true); +const pool = bytes.slice(PFST_HEADER_BYTES, PFST_HEADER_BYTES + poolBytes); +const patternName = new TextDecoder().decode( + pool.slice(patternOff, pool.indexOf(0, patternOff)), +); +check('cue0 pattern resolves to "Origin" through the pool', patternName === "Origin"); +check("pool offset 0 is the empty string", pool[0] === 0); + +console.log("\n.pfs → performance (publishing accepts either half of a save)"); +{ + // Round-trip our own encode first: decode must reproduce the timeline, and + // re-encoding must reproduce the bytes. + const back = decodePfst(bytes); + check("decode keeps the cue count", back.timeline.length === perf.timeline.length); + check("decode keeps title / loop / length", + back.title === "Smoke Set" && back.loop === true && back.length === 30); + check("decode keeps the pattern cue", back.timeline[0].pattern === "Origin"); + check("decode keeps a SPARSE param patch (flags carry which channels)", + JSON.stringify(back.timeline.find((cue) => cue.t === 5)?.param) === + JSON.stringify([null, 800, null, null])); + check("decode keeps an empty message cue", + back.timeline.some((cue) => cue.t === 20 && cue.message === "")); + check("re-encode reproduces the bytes", + Buffer.from(encodePfst(back)).equals(Buffer.from(bytes))); + + // And against the Director's own saves, which is the real contract: these + // .pfs files were written by Simone's tool, not by us. + const demos = path.resolve( + process.cwd(), + "..", + "_temp", + "patternflow-SimonePDA-feature-performance-director", + "director", + "demos", + ); + if (fs.existsSync(demos)) { + let checked = 0; + for (const file of fs.readdirSync(demos)) { + if (!file.endsWith(".pfs")) continue; + const raw = fs.readFileSync(path.join(demos, file)); + const decoded = decodePfst(new Uint8Array(raw)); + const reencoded = Buffer.from(encodePfst(decoded)); + check(`${file}: decode → encode is byte-identical`, reencoded.equals(raw)); + // And the decoded form must survive the same validation a paste does. + const verdict2 = validatePerformance(JSON.stringify(serializePerformance(decoded))); + check(`${file}: decoded form validates`, verdict2.ok); + checked++; + } + if (checked === 0) console.log(" -- no demo .pfs found (skipped)"); + } else { + console.log(" -- Director demos not in this checkout (skipped)"); + } + + check("rejects a file that is not a PFST table", + (() => { + try { + decodePfst(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])); + return false; + } catch { + return true; + } + })()); +} + +console.log("\nrefusals"); +check("rejects non-JSON", !validatePerformance("not json").ok); +check("rejects an empty timeline", !validatePerformance('{"timeline":[]}').ok); +const tooMany = JSON.stringify({ + timeline: Array.from({ length: 300 }, (_, i) => ({ t: i, param: [1, null, null, null] })), +}); +check("rejects >256 cues", !validatePerformance(tooMany).ok); +const bigPool = JSON.stringify({ + timeline: Array.from({ length: 200 }, (_, i) => ({ t: i, message: `m${"x".repeat(30)}${i}` })), +}); +check("rejects a >4KB string pool", !validatePerformance(bigPool).ok); + +console.log(failed ? `\n${failed} check(s) failed` : "\nall checks passed"); +process.exit(failed ? 1 : 0); diff --git a/web/src/app/api/community/decks/[id]/route.ts b/web/src/app/api/community/decks/[id]/route.ts index 589c573..d2fb1bd 100644 --- a/web/src/app/api/community/decks/[id]/route.ts +++ b/web/src/app/api/community/decks/[id]/route.ts @@ -8,6 +8,7 @@ import { checkDeckPattern, cleanPatternIds } from "@/lib/community/deckShare"; import { clearNotificationsFor, notifyDeckInclusion } from "@/lib/community/notify"; import { countPublicDecksByUser, getDeckStub, getPatternsForDeck } from "@/lib/community/queries"; import { rateLimit } from "@/lib/community/ratelimit"; +import { serializePerformance, validatePerformance } from "@/lib/community/performance"; import { deckPatterns, decks } from "@/lib/community/schema"; import { cleanDescription, cleanTitle } from "@/lib/community/validate"; import { cleanVisibility, type Visibility } from "@/lib/community/visibility"; @@ -135,6 +136,28 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st } } + // Optional attached performance (Director timeline). null detaches; a + // string is validated against the same limits the device's .pfs player + // enforces, and stored in canonical form so the pack's performance.json + // is stable across re-serializations. Serve-time zip decoration means no + // rebuild is queued either way. + let performanceJson: string | null | undefined; + if (raw.performanceJson !== undefined) { + if (raw.performanceJson === null || raw.performanceJson === "") { + performanceJson = null; + } else if (typeof raw.performanceJson !== "string") { + return Response.json({ error: "performanceJson must be a JSON string or null." }, { status: 400 }); + } else if (raw.performanceJson.length > 64 * 1024) { + return Response.json({ error: "Performance JSON is too large (max 64 KB)." }, { status: 400 }); + } else { + const verdict = validatePerformance(raw.performanceJson); + if (!verdict.ok) { + return Response.json({ error: `Performance: ${verdict.error}` }, { status: 400 }); + } + performanceJson = JSON.stringify(serializePerformance(verdict.perf), null, 2); + } + } + const db = getDb(); // The list as it stands — the fallback when no replacement came, and the @@ -177,6 +200,7 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st .set({ title, ...(description !== undefined ? { description } : {}), + ...(performanceJson !== undefined ? { performanceJson } : {}), visibility, updatedAt: new Date(), }) diff --git a/web/src/app/api/community/decks/[id]/zip/route.ts b/web/src/app/api/community/decks/[id]/zip/route.ts index 34b6a37..40c430c 100644 --- a/web/src/app/api/community/decks/[id]/zip/route.ts +++ b/web/src/app/api/community/decks/[id]/zip/route.ts @@ -2,7 +2,12 @@ import fs from "node:fs/promises"; import path from "node:path"; import { artifactDir } from "@/lib/community/builds"; import { communityEnabled } from "@/lib/community/db"; -import { deckZipFilename, ensureDeckZip, invalidateDeckZip } from "@/lib/community/deckZip"; +import { + decoratePackWithPerformance, + deckZipFilename, + ensureDeckZip, + invalidateDeckZip, +} from "@/lib/community/deckZip"; import { getDeck } from "@/lib/community/queries"; // GET /api/community/decks/[id]/zip — the deck as an installable pack. @@ -98,10 +103,13 @@ async function handleGet(_request: Request, context: { params: Promise<{ id: str ); } - return new Response(new Uint8Array(zip), { + // An attached performance rides the pack — see decoratePackWithPerformance. + const bytes = decoratePackWithPerformance(new Uint8Array(zip), deck.performanceJson); + + return new Response(Buffer.from(bytes), { headers: { "Content-Type": "application/zip", - "Content-Length": String(zip.byteLength), + "Content-Length": String(bytes.byteLength), "Content-Disposition": `attachment; filename="${deckZipFilename(deck.title)}"`, // The URL is stable and the contents are not: a deck can be rearranged // under the same address, so this is revalidated rather than kept. diff --git a/web/src/app/api/community/patterns/[id]/performance/route.ts b/web/src/app/api/community/patterns/[id]/performance/route.ts new file mode 100644 index 0000000..60dc571 --- /dev/null +++ b/web/src/app/api/community/patterns/[id]/performance/route.ts @@ -0,0 +1,175 @@ +import { eq } from "drizzle-orm"; + +import { isAdminSession } from "@/lib/community/admin"; +import { getAuth } from "@/lib/community/auth"; +import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { communityEnabled, getDb } from "@/lib/community/db"; +import { notifyPerformanceAdded } from "@/lib/community/notify"; +import { + encodePfst, + normalizePerformance, + pfsFilename, + serializePerformance, + summarizePerformanceJson, + validatePerformance, +} from "@/lib/community/performance"; +import { resolvePerformance } from "@/lib/community/performances"; +import { listPatternPerformances, newId } from "@/lib/community/queries"; +import { rateLimit } from "@/lib/community/ratelimit"; +import { patternPerformances, patterns } from "@/lib/community/schema"; +import { cleanDescription } from "@/lib/community/validate"; +import { canView } from "@/lib/community/visibility"; + +// GET /api/community/patterns/[id]/performance — the pattern's EFFECTIVE +// recording (author's own > author's pin > oldest; lib/community/performances). +// `?format=pfs` answers the packed show table the device's player reads, +// byte-identical to a Director save. +// +// POST — publish a recording for this pattern. Same social shape as firmware +// ports: authoring happens elsewhere (the Director PWA), anyone may publish +// one for a public pattern, it is live immediately, and the author outranks +// or pins. The JSON is validated against the device's PFST limits and stored +// canonically, so what one person uploads is exactly what another downloads. + +export async function OPTIONS(request: Request) { + return preflight(request); +} + +export async function GET(request: Request, context: { params: Promise<{ id: string }> }) { + const blocked = originBlocked(request); + if (blocked) return blocked; + return withCors(request, await handleGet(request, context)); +} + +export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { + const blocked = originBlocked(request); + if (blocked) return blocked; + return withCors(request, await handlePost(request, context)); +} + +async function loadPattern(id: string) { + const rows = await getDb() + .select({ + id: patterns.id, + title: patterns.title, + userId: patterns.userId, + visibility: patterns.visibility, + pinnedPerformanceId: patterns.pinnedPerformanceId, + }) + .from(patterns) + .where(eq(patterns.id, id)) + .limit(1); + return rows[0] ?? null; +} + +async function handleGet(request: Request, context: { params: Promise<{ id: string }> }) { + if (!communityEnabled()) { + return Response.json({ error: "Community is not configured." }, { status: 503 }); + } + const { id } = await context.params; + + const pattern = await loadPattern(id); + if (!pattern) return Response.json({ error: "No such pattern." }, { status: 404 }); + + if (pattern.visibility === "private") { + const session = await getAuth().api.getSession({ headers: request.headers }); + if (!canView(pattern.visibility, pattern.userId, session?.user.id ?? null, isAdminSession(session))) { + // Same body as a missing row — a 403 would confirm the id exists. + return Response.json({ error: "No such pattern." }, { status: 404 }); + } + } + + const effective = resolvePerformance(pattern, await listPatternPerformances(id)); + if (!effective) { + return Response.json({ error: "This pattern has no performance yet." }, { status: 404 }); + } + + const url = new URL(request.url); + if (url.searchParams.get("format") === "pfs") { + const perf = normalizePerformance(JSON.parse(effective.row.performanceJson)); + const bytes = encodePfst(perf); + return new Response(Buffer.from(bytes), { + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": String(bytes.byteLength), + "Content-Disposition": `attachment; filename="${pfsFilename(perf)}"`, + "Cache-Control": "public, max-age=0, must-revalidate", + }, + }); + } + + return Response.json({ + id: effective.row.id, + patternId: pattern.id, + source: effective.source, + performanceJson: effective.row.performanceJson, + summary: summarizePerformanceJson(effective.row.performanceJson), + recordedBy: effective.row.displayUsername ?? effective.row.username ?? null, + }); +} + +async function handlePost(request: Request, context: { params: Promise<{ id: string }> }) { + if (!communityEnabled()) { + return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); + } + + const session = await getAuth().api.getSession({ headers: request.headers }); + if (!session) { + return Response.json({ error: "Sign in to publish a performance." }, { status: 401 }); + } + + if (!rateLimit(`performance:${session.user.id}`, 5, 60_000)) { + return Response.json({ error: "Too many performances — wait a minute and try again." }, { status: 429 }); + } + + const { id } = await context.params; + const pattern = await loadPattern(id); + // Private patterns take no recordings from strangers — same 404 as missing. + if (!pattern || !canView(pattern.visibility, pattern.userId, session.user.id)) { + return Response.json({ error: "No such pattern." }, { status: 404 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid JSON body." }, { status: 400 }); + } + const raw = body as Record; + + if (typeof raw.performanceJson !== "string" || raw.performanceJson.length === 0) { + return Response.json({ error: "performanceJson (a Director Save-JSON string) is required." }, { status: 400 }); + } + if (raw.performanceJson.length > 64 * 1024) { + return Response.json({ error: "Performance JSON is too large (max 64 KB)." }, { status: 400 }); + } + const verdict = validatePerformance(raw.performanceJson); + if (!verdict.ok) { + return Response.json({ error: `Performance: ${verdict.error}` }, { status: 400 }); + } + + const note = cleanDescription(raw.note); + if (note === undefined) { + return Response.json({ error: "Note is too long (max 2000 chars)." }, { status: 400 }); + } + + const performanceId = newId(); + await getDb().insert(patternPerformances).values({ + id: performanceId, + patternId: pattern.id, + userId: session.user.id, + performanceJson: JSON.stringify(serializePerformance(verdict.perf), null, 2), + note, + createdAt: new Date(), + }); + + await notifyPerformanceAdded({ + patternOwnerId: pattern.userId, + patternId: pattern.id, + patternTitle: pattern.title, + performanceId, + actorId: session.user.id, + }); + + return Response.json({ id: performanceId }, { status: 201 }); +} diff --git a/web/src/app/api/community/patterns/[id]/route.ts b/web/src/app/api/community/patterns/[id]/route.ts index f5f7463..e5bc02c 100644 --- a/web/src/app/api/community/patterns/[id]/route.ts +++ b/web/src/app/api/community/patterns/[id]/route.ts @@ -3,8 +3,17 @@ import { isAdminSession } from "@/lib/community/admin"; import { getAuth } from "@/lib/community/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; import { communityEnabled, getDb } from "@/lib/community/db"; -import { clearNotificationsFor, notifyPortPinned } from "@/lib/community/notify"; -import { getPattern, getPatternStub, getPortStub } from "@/lib/community/queries"; +import { + clearNotificationsFor, + notifyPerformancePinned, + notifyPortPinned, +} from "@/lib/community/notify"; +import { + getPattern, + getPatternStub, + getPerformanceStub, + getPortStub, +} from "@/lib/community/queries"; import { rateLimit } from "@/lib/community/ratelimit"; import { patternHeaders, patterns } from "@/lib/community/schema"; import { buildStoredPatternCode, lineageFrom } from "@/lib/community/license"; @@ -200,6 +209,25 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st } } + // Same picking rule for performance recordings — the author's call which + // ride represents the pattern (lib/community/performances.ts). + let pinnedPerformanceId = pattern.pinnedPerformanceId; + let newlyPinnedPerformance: Awaited> | null = null; + if (raw.pinnedPerformanceId !== undefined) { + if (raw.pinnedPerformanceId === null || raw.pinnedPerformanceId === "") { + pinnedPerformanceId = null; + } else if (typeof raw.pinnedPerformanceId === "string") { + const recording = await getPerformanceStub(raw.pinnedPerformanceId); + if (!recording || recording.patternId !== id) { + return Response.json({ error: "No such performance on this pattern." }, { status: 400 }); + } + if (recording.id !== pattern.pinnedPerformanceId) newlyPinnedPerformance = recording; + pinnedPerformanceId = recording.id; + } else { + return Response.json({ error: "Unknown pinned performance." }, { status: 400 }); + } + } + // Compare the bodies with any licence wrapping removed, so re-saving without // touching the code isn't mistaken for a code change. let bareCode = stripShareWrapping(pattern.code); @@ -261,6 +289,7 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st codeCpp, visibility, pinnedHeaderId, + pinnedPerformanceId, updatedAt: new Date(), }) .where(eq(patterns.id, id)); @@ -285,6 +314,16 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st }); } + if (newlyPinnedPerformance) { + await notifyPerformancePinned({ + recorderId: newlyPinnedPerformance.userId, + patternId: id, + patternTitle: title, + performanceId: newlyPinnedPerformance.id, + actorId: session.user.id, + }); + } + return Response.json({ ok: true, hasCpp: codeCpp !== null, diff --git a/web/src/app/api/community/patterns/route.ts b/web/src/app/api/community/patterns/route.ts index 511340e..8147ed4 100644 --- a/web/src/app/api/community/patterns/route.ts +++ b/web/src/app/api/community/patterns/route.ts @@ -6,6 +6,7 @@ import { notifyForkPublished } from "@/lib/community/notify"; import { countFeed, getPatternStub, + likedPatternIds, listFeed, newId, parseFeedSort, @@ -66,19 +67,19 @@ async function handleGet(request: Request) { const offset = clampInt(params.get("offset"), 0, 0, 1_000_000); const size = clampInt(params.get("size"), 12, 1, MAX_FEED_PAGE_SIZE); - // The infinite scroll refills through here, so `liked` needs the same viewer - // the first page was rendered for — without it page two of your liked list - // would come back as the whole wall. - const session = - sort === "liked" ? await getAuth().api.getSession({ headers: request.headers }) : null; + // The infinite scroll refills through here, so every page needs the same + // viewer the first paint had: `liked` for the subset itself, and the card + // hearts on any sort — without it page two would arrive unlit. + const session = await getAuth().api.getSession({ headers: request.headers }); const viewerId = session?.user.id ?? null; const [items, total] = await Promise.all([ listFeed({ sort, hardwareOnly, limit: size, offset, viewerId }), countFeed(hardwareOnly), ]); + const likedIds = await likedPatternIds(viewerId, items.map((item) => item.id)); - return Response.json({ items: items.map(toCardItem), total }); + return Response.json({ items: items.map((item) => toCardItem(item, likedIds)), total }); } async function handlePost(request: Request) { diff --git a/web/src/app/api/community/performances/[id]/route.ts b/web/src/app/api/community/performances/[id]/route.ts new file mode 100644 index 0000000..df721a3 --- /dev/null +++ b/web/src/app/api/community/performances/[id]/route.ts @@ -0,0 +1,118 @@ +import { eq } from "drizzle-orm"; + +import { isAdminSession } from "@/lib/community/admin"; +import { getAuth } from "@/lib/community/auth"; +import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { communityEnabled, getDb } from "@/lib/community/db"; +import { clearNotificationsFor } from "@/lib/community/notify"; +import { + encodePfst, + normalizePerformance, + pfsFilename, +} from "@/lib/community/performance"; +import { getPatternStub, getPerformanceStub } from "@/lib/community/queries"; +import { patternPerformances, patterns } from "@/lib/community/schema"; +import { canView } from "@/lib/community/visibility"; + +// GET /api/community/performances/[id] — one specific recording, as the +// stored canonical JSON, or `?format=pfs` for the packed show table. +// The pattern page's per-recording download links point here. +// DELETE — the recorder withdraws it, or a moderator removes it. The pattern's +// author does not delete other people's recordings (same rule as ports) +// — they out-rank them with a pin or their own recording instead. + +export async function GET(request: Request, context: { params: Promise<{ id: string }> }) { + const blocked = originBlocked(request); + if (blocked) return blocked; + return withCors(request, await handleGet(request, context)); +} + +export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) { + const blocked = originBlocked(request); + if (blocked) return blocked; + return withCors(request, await handleDelete(request, context)); +} + +export const OPTIONS = preflight; + +async function handleGet(request: Request, context: { params: Promise<{ id: string }> }) { + if (!communityEnabled()) { + return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); + } + + const { id } = await context.params; + const rows = await getDb() + .select({ + id: patternPerformances.id, + patternId: patternPerformances.patternId, + performanceJson: patternPerformances.performanceJson, + }) + .from(patternPerformances) + .where(eq(patternPerformances.id, id)) + .limit(1); + const row = rows[0]; + if (!row) return Response.json({ error: "Performance not found." }, { status: 404 }); + + // Visible exactly when its pattern is: a recording of a private pattern is + // part of that pattern's private page, not an unlisted side door. + const pattern = await getPatternStub(row.patternId); + if (!pattern) return Response.json({ error: "Performance not found." }, { status: 404 }); + if (pattern.visibility === "private") { + const session = await getAuth().api.getSession({ headers: request.headers }); + if (!canView(pattern.visibility, pattern.userId, session?.user.id ?? null, isAdminSession(session))) { + return Response.json({ error: "Performance not found." }, { status: 404 }); + } + } + + const url = new URL(request.url); + if (url.searchParams.get("format") === "pfs") { + const perf = normalizePerformance(JSON.parse(row.performanceJson)); + const bytes = encodePfst(perf); + return new Response(Buffer.from(bytes), { + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": String(bytes.byteLength), + "Content-Disposition": `attachment; filename="${pfsFilename(perf)}"`, + "Cache-Control": "public, max-age=0, must-revalidate", + }, + }); + } + + return new Response(row.performanceJson, { + headers: { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="performance-${row.id}.json"`, + "Cache-Control": "public, max-age=0, must-revalidate", + }, + }); +} + +async function handleDelete(request: Request, context: { params: Promise<{ id: string }> }) { + if (!communityEnabled()) { + return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); + } + + const session = await getAuth().api.getSession({ headers: request.headers }); + if (!session) { + return Response.json({ error: "Sign in to remove your performance." }, { status: 401 }); + } + + const { id } = await context.params; + const row = await getPerformanceStub(id); + if (!row) return Response.json({ error: "Performance not found." }, { status: 404 }); + if (row.userId !== session.user.id && !isAdminSession(session)) { + return Response.json({ error: "You can only remove your own performances." }, { status: 403 }); + } + + const db = getDb(); + await db.delete(patternPerformances).where(eq(patternPerformances.id, id)); + // A pin pointing at a deleted recording must not dangle — resolution would + // cope, but the author's stored choice should reflect reality. + await db + .update(patterns) + .set({ pinnedPerformanceId: null }) + .where(eq(patterns.pinnedPerformanceId, id)); + await clearNotificationsFor({ sourceId: id }); + + return Response.json({ ok: true }); +} diff --git a/web/src/app/community/d/[id]/DeckDetailClient.tsx b/web/src/app/community/d/[id]/DeckDetailClient.tsx index 28b05c3..fb6e6f3 100644 --- a/web/src/app/community/d/[id]/DeckDetailClient.tsx +++ b/web/src/app/community/d/[id]/DeckDetailClient.tsx @@ -7,6 +7,8 @@ import PatternCard from "@/components/community/PatternCard"; import ReportModal from "@/components/community/ReportModal"; import ShareDeckPackModal from "@/components/community/ShareDeckPackModal"; import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; +import { summarizePerformanceJson } from "@/lib/community/performance"; +import { readPerformanceFile } from "@/lib/community/performanceFile"; import { deckItems, deckReplace, @@ -34,6 +36,8 @@ export type DeckView = { title: string; description: string | null; visibility: string; + /** Attached Director performance (canonical JSON), or null. */ + performanceJson: string | null; createdAt: string; // ISO username: string | null; displayUsername: string | null; @@ -54,6 +58,7 @@ export default function DeckDetailClient({ const [busy, setBusy] = useState(false); const [reportOpen, setReportOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); + const [perfOpen, setPerfOpen] = useState(false); const [confirmCopy, setConfirmCopy] = useState(false); const [confirmReplace, setConfirmReplace] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); @@ -397,6 +402,20 @@ export default function DeckDetailClient({ {deck.description &&

{deck.description}

} + {(() => { + const perf = summarizePerformanceJson(deck.performanceJson); + if (!perf) return null; + const m = Math.floor(perf.seconds / 60); + const s = perf.seconds % 60; + return ( +

+ ♦ Includes a timed performance — “{perf.title}” ({perf.cues} cues,{" "} + {m}:{s < 10 ? `0${s}` : s}). It installs with the pack and plays from the + device’s Sequences page. +

+ ); + })()} + {isOwner && (
+ ); +} + +// A Director performance rides the deck's pack: performance.json (editable +// source) + the packed .pfs the device's Sequences page plays. Author it in +// the Director PWA (Save JSON), paste or drop the file here. The server +// re-validates against the device's limits, so a save that succeeds is one +// the panel will actually load. +function PerformanceModal({ + deckId, + initialJson, + onSaved, + onClose, +}: { + deckId: string; + initialJson: string | null; + onSaved: () => void; + onClose: () => void; +}) { + const [json, setJson] = useState(initialJson ?? ""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const send = async (value: string | null) => { + setBusy(true); + setError(null); + try { + const response = await fetch(communityApiUrl(`/api/community/decks/${deckId}`), { + method: "PATCH", + ...COMMUNITY_FETCH_INIT, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ performanceJson: value }), + }); + const payload = (await response.json()) as { error?: string }; + if (!response.ok) { + setError(payload.error ?? "Could not save."); + return; + } + onSaved(); + } catch { + setError("Network error."); + } finally { + setBusy(false); + } + }; + + // Either half of a Director save — the packed .pfs is decoded back to the + // editable timeline, which is what the deck stores and re-encodes for packs. + const pickFile = (file: File | undefined) => { + if (!file) return; + setError(null); + void readPerformanceFile(file).then((result) => { + if (result.ok) setJson(result.json); + else setError(result.error); + }); + }; + + return ( +
+
event.stopPropagation()}> +
+ Deck performance + +
+
+ +