diff --git a/CLAUDE.md b/CLAUDE.md index f446b6a4..70ebf6d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -259,9 +259,12 @@ The project uses Claude Code agents in defined roles. The user is the **Product | πŸ‘Ύ | **Reviewer** | **Fable** (Opus only if Fable is unavailable) | Pre-merge check | Runs at PR merge over the whole branch diff (Event 2, gate 5), and pre-commit on the staged diff when the commit is large or the product owner asks (see *Reviewer at commit-time*). The model is fixed, not a per-run choice. Complements CodeRabbit (which handles line-level bugs in the PR). | | πŸ›Έ | **Tester** | Sonnet | Verification | Writes tests, verifies architectural rules in code | | πŸ’€ | **Runner** | Haiku | Quick checks | Runs MoonDeck scripts, platform boundary checks, build verification | +| πŸ”¬ | **Researcher** | **Fable** (same as Reviewer) | Investigation | Read-only fan-out over the codebase (or friend repos / datasheets) to answer a scoped question before a design or change β€” an interface inventory, a blast-radius map, a prior-art survey. Returns the conclusion, not file dumps. Feeds the Architect's plan and the Developer's spec. | Agents work in parallel on independent steps. Agents never commit; only the product owner approves commits after testing. +**Delegate the mechanical roles; don't absorb them.** The main loop tends to *become* the Runner/Tester/Researcher rather than spawning them β€” running every gate inline, writing every test itself. That is the right call for a **single fast check** (a lone `check_specs`, one `git status`) where the spawn round-trip costs more than the work. But **delegate when the work is parallelizable or substantial**: the pre-commit gate fan-out is textbook **Runner** work (Haiku, in parallel, cheaper and faster than serial-inline); pinning a fixed bug with a regression test through the real code path is a clean **Tester** spec (find the mechanism yourself, hand Tester the spec, verify the result); a broad "map the interface / blast radius / prior art before we design" is **Researcher** work (Fable, read-only fan-out). The heuristic: *parallelizable or substantial β†’ delegate; a single fast check β†’ inline.* + ## Build How to build, flash, run, monitor, and check the project for every target: [docs/building.md](docs/building.md). Per-script reference: [moondeck/MoonDeck.md](moondeck/MoonDeck.md). diff --git a/docs/MIGRATING.md b/docs/MIGRATING.md index adcb8c06..18ed798e 100644 --- a/docs/MIGRATING.md +++ b/docs/MIGRATING.md @@ -20,6 +20,28 @@ projectMM ships **no migration code**: the persistence layer is robust by defaul ## Unreleased (`next-iteration`) +### The three parallel LED drivers merge into one `ParallelLedDriver` with a `peripheral` selector (2026-07-23) + +`MultiPinLedDriver`, `MoonLedDriver`, and `ParlioLedDriver` are now one registered module, **`ParallelLedDriver`**, whose `peripheral` control picks which DMA peripheral drives the parallel WS2812 bus. They were always the same driver with a different bus backend; the merge makes that one card with a dropdown, offering only the peripherals the chip supports. + +| Old registered type | New | +|---|---| +| `MultiPinLedDriver` | `ParallelLedDriver` + `peripheral` = `i80` (esp_lcd: LCD_CAM on S3/P4, I2S on classic) | +| `MoonLedDriver` | `ParallelLedDriver` + `peripheral` = `MoonI80` (own-GDMA below esp_lcd, LCD_CAM) | +| `ParlioLedDriver` | `ParallelLedDriver` + `peripheral` = `Parlio` (P4) | + +**Action: re-add the driver.** A persisted module whose type is one of the three old names no longer resolves (the type isn't registered), so the robust loader drops it on boot β€” the driver, and its pins/settings, vanish from the tree. Add a **Parallel LED** driver again, choose the `peripheral` your board uses (the same backend the old type named β€” see the table), and re-enter its `pins` / `ledsPerPin` plus whatever the chosen peripheral needs: `i80` has `clockPin`/`dcPin`, `MoonI80` has `clockPin` + the ring/expander controls, `Parlio` has no clock or DC pins at all. The web installer's board catalog already names the new type, so a fresh install or a catalog re-inject wires it correctly; only a device carrying an OLD persisted tree needs the manual re-add. + +### The per-driver `preset` control is renamed to `lightPreset` (2026-07-23) + +The correction Select every driver exposes (channel order / RGBW synthesis) is renamed `preset` β†’ `lightPreset`, so the UI label reads unambiguously next to a driver's other controls. + +| Old | New | +|---|---| +| control `preset` | `lightPreset` | + +**Action: nothing.** The saved value survives the rename (see the `lightPreset` [persistence contract](moonmodules/light/drivers.md#led-driver-details) for how a driver's preset reference is stored and re-resolved). Only an external script or automation that POSTs the control by name (`/api/control` with `"control":"preset"`) must switch to `lightPreset`. + ### `AudioService`: the `sync` control becomes `mode` + `send audio`, and `simulate` is renumbered (2026-07-22) The audio module's identity is now a single `mode` control (Local audio / Receive network / Simulate), each showing only its own detail controls, replacing the separate `sync` (off / send / receive) toggle. Broadcasting the locally-analyzed frame moved to a `send audio` switch, meaningful only in Local mode. `simulate` was also renumbered, from a five-option list to two. @@ -62,9 +84,9 @@ The LED driver module types and several controls were renamed so the UI reads in **Action: re-add the module, then re-set `pinExpander` / `doubleBuffer` if you had changed them.** -A device whose persisted config names the old module type loads a module type that no longer exists β€” the unknown type is ignored, so **the driver is absent from the tree on boot**. Re-add it (`MultiPinLedDriver` or `MoonLedDriver`) and re-enter its controls. Within a re-added driver, the two renamed *settable* controls (`pinExpander`, `doubleBuffer`) read as absent β†’ they take their defaults (`pinExpander` off, `doubleBuffer` on); set them again if your board needs otherwise. `frameTime` and `renderWait` are read-only KPIs β€” nothing to restore. +A device whose persisted config names the old module type loads a module type that no longer exists β€” the unknown type is ignored, so **the driver is absent from the tree on boot**. Re-add a **Parallel LED** driver (the single type the two later merged into β€” see the 2026-07-23 entry above for the `peripheral` value that matches the old `I80LedDriver` / `MoonI80LedDriver`) and re-enter its controls. Within a re-added driver, the two renamed *settable* controls (`pinExpander`, `doubleBuffer`) read as absent β†’ they take their defaults (`pinExpander` off, `doubleBuffer` on); set them again if your board needs otherwise. `frameTime` and `renderWait` are read-only KPIs β€” nothing to restore. -`RmtLedDriver` and `ParlioLedDriver` are unchanged. The `pins` / `ledsPerPin` / `clockPin` / `latchPin` / `loopback*` controls are unchanged. +This rename left `RmtLedDriver` untouched, and `ParlioLedDriver` untouched *at the time*; the later 2026-07-23 entry above then merges `ParlioLedDriver` into `ParallelLedDriver` along with the other two. The `pins` / `ledsPerPin` / `clockPin` / `latchPin` / `loopback*` controls are unchanged by this rename. --- diff --git a/docs/adr/0016-one-parallel-led-driver-runtime-peripheral-strategy.md b/docs/adr/0016-one-parallel-led-driver-runtime-peripheral-strategy.md new file mode 100644 index 00000000..d372ed5f --- /dev/null +++ b/docs/adr/0016-one-parallel-led-driver-runtime-peripheral-strategy.md @@ -0,0 +1,43 @@ +# 16. One parallel LED driver with a runtime peripheral strategy, not three CRTP subclasses + +Date: 2026-07-23 + +## Status + +Accepted + +Builds on [ADR-0014](0014-own-i80-dma-driver-below-esp-lcd.md) (the own-DMA MoonI80 backend), which this consolidates alongside the esp_lcd i80 and Parlio backends. + +## Context + +The parallel-LED output was **four classes**: a CRTP base `ParallelLedDriver` holding all shared logic (slicing, the fused correct+transpose encode, the async double-buffer, the loopback self-test, the dead-frame guard) and three concrete CRTP subclasses, each a full registered `MoonModule`: `MoonLedDriver` (own-GDMA LCD_CAM + streaming ring + 74HCT595 expander), `MultiPinLedDriver` (esp_lcd i80 on S3/P4 LCD_CAM, I2S on classic), `ParlioLedDriver` (P4 Parlio). Because each was separately factory-registered, the UI add-module picker offered all three on *every* board, including chips that cannot run them (`lanesAvailable() == 0`). + +The CRTP base existed for exactly one reason: to reach the peripheral via compile-time dispatch. Every `derived()->` call is a peripheral operation; there is no non-peripheral use of CRTP. And crucially, every such call is **per-frame or per-reinit, never per-light** β€” the per-light encode operates on the raw `uint8_t*` the peripheral hands back, and never calls into the peripheral. CRTP's guarantee ("no runtime indirection") therefore protected calls that don't exist on the hot path. + +The product owner wanted one user-facing "Parallel LED" module with a `peripheral` dropdown that surfaces the shared controls plus the selected peripheral's unique controls, allocating only the selected backend. + +## Decision + +Collapse the four classes into **one registered `ParallelLedDriver`** (a plain `MoonModule`) that holds a **`LedPeripheral*` runtime strategy**, chosen by a `peripheral` Select. The three ex-subclasses become `LedPeripheral` implementations (`I80Peripheral`, `MoonI80Peripheral`, `ParlioPeripheral`), each self-registering its factory + label with a static registry, gated by its chip's `CONFIG_SOC_*` so a board links only its usable backends. The Select is board-filtered to `lanesAvailable() > 0` and uses stable string labels (not indices) so a catalog config is portable across chips. `RmtLedDriver` stays a separate module (a different shape: N independent per-pin RMT channels, not one lockstep DMA bus). + +Because CRTP protected only per-frame calls, replacing it with one vtable dispatch per frame is free (one vcall against thousands of microseconds of frame work). The base's shared body did not change; only the *dispatch to the peripheral* moved from compile-time to runtime. + +Two capabilities fall out of the single-object design and are included: +- **A peripheral-block claim guard**: the chip has one of each hardware block (one LCD_CAM, one Parlio, one I2S), so two live drivers on the same block corrupt each other. A driver reports its block via an RTTI-free `hwBlock()` virtual (ESP32 is `-fno-rtti`), gated on `inited_` so only a driver actually holding the bus claims it; a sibling wanting the same block idles with a clear status. Different blocks (RMT + Parlio + i80 on a P4) coexist. +- **`pinExpander` auto-clear**: a peripheral that cannot host the 74HCT595 (Parlio, classic i80) silently degrades an enabled expander back to direct mode rather than idling on an unfixable error. + +Core stays domain-neutral: the backend registry and the peripheral interface live in `src/light/drivers/`; the only core touch is a string-label apply path in `Control.cpp` (a Select value may be an option label, not just an index). + +The alternatives weighed and rejected: **keep CRTP + one registered wrapper** (still three code paths, still the wrong-chip picker problem, no runtime switch); **fold RmtLed in behind the same interface** (a leaky abstraction carrying single-DMA-bus ops half the implementers cannot honor β€” an expansion, not a reduction). + +## Consequences + +**Net subtraction plus a feature.** Four classes become one module + one interface + three stripped backends; one control set, one lifecycle, one registry entry, one UI card. The backends shrink (they lose the `MoonModule`/control/lifecycle scaffolding). The add-module picker offers one "Parallel LED" card on every board, and the `peripheral` dropdown shows only what the chip supports; switching it live re-surfaces that peripheral's controls and re-inits the bus with no reflash. + +**One new hot-path fact, and it is free:** one virtual dispatch per frame to reach the peripheral. This is the *only* runtime indirection added, and it is per-frame, not per-light. + +**The runtime backend became a swappable object, which the persistence and structural-mutation paths had to learn about** β€” the robustness bugs this branch also fixes and records in [lessons.md](../history/lessons.md): a control whose backing variable lives on the (swappable) backend was lost on reload unless persistence re-binds the backend first; stopping the encode worker before a structural mutation had to reach the worker's owner (`Drivers`) regardless of which subtree was mutated (and cover *every* mutator, not three of four); a positional child reconciler had to skip an unresolvable saved entry rather than drop the tail; and a live backend free had to fire the same worker quiesce as a tree mutation. Each is a core-level fix with a regression test (the four consolidation-branch lessons). The lesson embedded in the ADR: moving a compile-time type choice to a runtime object makes every path that assumed a fixed object (persistence overlay order, per-parent worker quiesce) a place to check. + +The migration cost is documented, not coded (per [ADR-0013](0013-no-migration-code-robust-persistence-plus-documented-breaks.md)): a field device's persisted `MoonLedDriver`/`MultiPinLedDriver`/`ParlioLedDriver` type no longer resolves, so the module drops on boot and the user re-adds a Parallel LED driver and picks the peripheral β€” a `MIGRATING.md` entry covers it, and the web-installer catalog names the new type so a fresh install is correct. + +The design intent and staged plan are the [consolidation plan](../history/plans/); this ADR is the decision record. diff --git a/docs/adr/README.md b/docs/adr/README.md index 723d75e6..f385407f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,3 +25,4 @@ Agents do not read this directory automatically, only when a decision's rational | [0013](0013-no-migration-code-robust-persistence-plus-documented-breaks.md) | No migration code β€” robust persistence + documented breaks | Accepted | | [0014](0014-own-i80-dma-driver-below-esp-lcd.md) | Our own i80 DMA driver, one level below esp_lcd | Accepted | | [0015](0015-library-is-a-tag-not-a-folder.md) | The source tree splits by domain/type; library origin is a tag, not a folder | Accepted | +| [0016](0016-one-parallel-led-driver-runtime-peripheral-strategy.md) | One parallel LED driver with a runtime peripheral strategy, not three CRTP subclasses | Accepted | diff --git a/docs/architecture.md b/docs/architecture.md index e8159115..c114328f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -This document is the agreed-up-front **architecture contract**: what projectMM is designed to be. Most of it describes the system as it is today; a few load-bearing design decisions are settled but not yet implemented. Those are marked **🚧: designed, not implemented yet (high priority for release 2)**. The 🚧 marker means the design is committed (this is how it *will* work, and code should be written toward it), not that it's optional or undecided. Anything without the marker is live today. +This document is the agreed-up-front **architecture contract**: what projectMM is designed to be. Most of it describes the system as it is today. A design described here is committed (this is the intended behavior, and code is written toward it), not optional or undecided. Coding conventions live in [coding-standards.md](coding-standards.md); how to build and run lives in [building.md](building.md); what is tested lives in [testing.md](testing.md). @@ -164,7 +164,7 @@ When one module produces data another module reads on the hot path, the pattern - The producer exposes the struct via a `const`-returning getter (or a `setX(const Foo*)` setter on the consumer). - The **consumer holds a `const Foo*`** received once at wiring time in `main.cpp`, and reads it on the hot path each frame. -No registry, no subscription, no event bus. The consumer reads the latest value when it needs it; if the producer wrote nothing this tick, the consumer sees the previous value (acceptable for the kinds of data this exchanges: small state structs, periodic captures). This pull pattern is lock-free **for a small POD struct overwritten in place**: a reader on another core might catch a half-updated struct, but the result is one slightly-inconsistent read of a few fields that self-corrects next tick, visually harmless for the gyro/sensor data this carries, and cheaper than a lock. That tolerance does **not** extend to a large frame buffer the consumer copies out wholesale (an LED DMA buffer, an ArtNet packet): there a half-written read is a visible glitch, so that hand-off uses the 🚧 two-core double-buffer swap from [Β§ Parallelism](#parallelism), not this lock-free pull. +No registry, no subscription, no event bus. The consumer reads the latest value when it needs it; if the producer wrote nothing this tick, the consumer sees the previous value (acceptable for the kinds of data this exchanges: small state structs, periodic captures). This pull pattern is lock-free **for a small POD struct overwritten in place**: a reader on another core might catch a half-updated struct, but the result is one slightly-inconsistent read of a few fields that self-corrects next tick, visually harmless for the gyro/sensor data this carries, and cheaper than a lock. That tolerance does **not** extend to a large frame buffer the consumer copies out wholesale (an LED DMA buffer, an ArtNet packet): there a half-written read is a visible glitch, so that hand-off uses the two-core double-buffer swap from [Β§ Parallelism](#parallelism), not this lock-free pull. **Push through a domain-neutral sink.** When the producer should hand bytes to a generic core service rather than expose a struct, the core defines a narrow interface and the producer pushes to it. The producer owns the data and its wire format; the core sink (the interface's implementer) knows only "take these bytes and do my generic job"; it has zero knowledge of what the bytes mean or which domain produced them. `BinaryBroadcaster` (`HttpServerModule` implements it: "broadcast these bytes to all WebSocket clients") is the example; the producer side lives in the light domain (see [Β§ The pipeline](#the-pipeline)). @@ -274,7 +274,7 @@ Services are **user-add/deletable children of the `Services` container** β€” the Two domain-neutral services let several controllers act as one installation. They're core because nothing about them is light-specific; any domain spanning multiple devices uses the same two. - **Discovery**: devices find each other via mDNS. `NetworkModule` advertises each device today; this is live. -- **🚧 Clock sync**: one leader broadcasts its elapsed time (millis); followers compute their offset, targeting sub-millisecond accuracy. A shared monotonic clock is the foundation any cross-device coordination builds on. The committed design; not yet wired. +- **Clock sync**: one leader broadcasts its elapsed time (millis); followers compute their offset, targeting sub-millisecond accuracy. A shared monotonic clock is the foundation any cross-device coordination builds on. The committed design; not yet wired. What the synced clock is *for* is a domain question; the light domain's use of it (synced animation across a wall) is in [Β§ Multi-device sync](#multi-device-sync). @@ -373,7 +373,7 @@ Each layer references the shared Layouts. The layer builds its mapping by walkin Effects produce light colours. They write into the Layer's buffer, which represents a logical grid. The Layer determines the buffer's dimensions (width, height, depth) from the Layouts and its modifiers. Effects receive these logical dimensions and elapsed time (millis) as their rendering context. They compute light positions from the buffer index (e.g. `x = i % width`, `y = i / width`). -Effects use elapsed time for animation, not frame count. Animation speed becomes frame-rate independent: an effect looks the same at 30 fps and 60 fps. This is also what makes the 🚧 cross-device clock sync work: a shared elapsed-time base means synced visuals across controllers (see [Β§ Multi-device sync](#multi-device-sync)). +Effects use elapsed time for animation, not frame count. Animation speed becomes frame-rate independent: an effect looks the same at 30 fps and 60 fps. This is also what makes the cross-device clock sync work: a shared elapsed-time base means synced visuals across controllers (see [Β§ Multi-device sync](#multi-device-sync)). Effects know nothing about hardware, protocols, physical LED layout, or mapping. They only see the logical grid the layer provides. @@ -469,7 +469,7 @@ The shared output buffer is necessary when blend+map writes to arbitrary physica Each driver (a MoonModule) speaks one protocol: -- **LED drivers**: WS2812 via RMT (multi-pin), plus two parallel-output paths on the newer chips. The S3's LCD_CAM i80 bus ([MultiPinLedDriver](moonmodules/light/moxygen/MultiPinLedDriver.md)) drives exactly 8 data GPIOs β€” the i80 bus claims every data line of its width, so a partial set is rejected. The P4's Parlio peripheral ([ParlioLedDriver](moonmodules/light/moxygen/ParlioLedDriver.md)) drives 1–8 lanes β€” it takes the data GPIOs directly, so any count up to 8 is valid. Both are DMA-driven. Platform-specific; all behind the platform boundary. +- **LED drivers**: WS2812 via RMT (multi-pin), plus one DMA-driven parallel driver ([ParallelLedDriver](moonmodules/light/drivers.md#parallelled)) whose `peripheral` control picks the bus backend the chip supports β€” the `i80` bus (LCD_CAM on the S3/P4/S31, and the classic ESP32's I2S peripheral in i80 mode, which is the classic's only >8-lane parallel route β€” IDF's `esp_lcd` picks the backend per chip), our own-GDMA MoonI80 (LCD_CAM, adds the streaming ring + 74HCT595 expander), or the P4's Parlio. All are DMA-driven and behind the platform boundary; the driver rounds an i80 bus up around whatever pin count is configured (any count from 1) and parks unused lanes on a pin already driven. - **DMX / ArtNet**: sends DMX over UDP. Supports addressable LEDs and conventional DMX fixtures (pars, moving heads, dimmers). - **Preview**: streams light data to the web UI via WebSocket. - **Desktop output**: SDL2 or terminal for visual preview. Desktop also serves as a high-speed processing node, driving lights via ArtNet/DDP over the network. @@ -497,7 +497,7 @@ The result is a memory ladder that tracks configuration exactly: module-absent ### Buffer types - **Layer buffers**: one per active layer, holds the logical light data for one effect chain. Allocated in PSRAM when available. On memory-constrained devices, consumers may read from the layer buffer directly (no mapping, no blending, no physical buffer needed). -- **Physical buffer**: when present, holds the blended+mapped output. It is a *blend* buffer, needed only for compositing (>1 layer, or any alpha/additive blend); it is not what provides producer/consumer parallelism. Under the 🚧 [two-core handover](#parallelism), parallelism comes from the consumer's own working copy, the encoded DMA buffer for a clockless LED driver, or the kernel socket buffer for ArtNet, which decouples the producer (filling the next Layer frame) from the consumer (transmitting the previous one). +- **Physical buffer**: when present, holds the blended+mapped output. It is a *blend* buffer, needed only for compositing (>1 layer, or any alpha/additive blend); it is not what provides producer/consumer parallelism. Under the [two-core handover](#parallelism), parallelism comes from the consumer's own working copy, the encoded DMA buffer for a clockless LED driver, or the kernel socket buffer for ArtNet, which decouples the producer (filling the next Layer frame) from the consumer (transmitting the previous one). - **Mapping LUT**: flat lookup table for logicalβ†’physical. Read-only during rendering. PSRAM is fine: sequential reads are cache-friendly. All buffers are raw `uint8_t*` arrays sized `channelsPerLight * nrOfLights`. There is no pre-allocated per-channel array and no fixed channel layout: `channelsPerLight` is a runtime value (a `uint8_t`, so 1–255), so RGB (3), RGBW (4), and multi-channel DMX fixtures all use the same code path; the buffer simply gets wider. Channel layout is configured via offsets (see MoonLight's [LightsHeader](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/LightsHeader.h) pattern). @@ -548,8 +548,8 @@ The architecture does not assume PSRAM is present. Buffer counts and sizes are d How lighting uses the core [multi-device runtime](#multi-device-runtime) (discovery + clock sync) to drive an installation spanning multiple controllers: -- **🚧 Synced visuals from the shared clock.** Effects animate off elapsed time ([Β§ Effects](#effects)), so feeding them the leader's synced clock instead of each device's local one makes a wall of controllers animate in lockstep, regardless of each one's frame rate. This is the light-domain payoff of the core clock sync. -- **🚧 Light distribution**: one device sending rendered light data to another uses the existing ArtNet / E1.31 / DDP standards (the ArtNet *driver* already sends to fixtures today; device-to-device distribution as a sync topology is the part not yet wired). No bespoke protocol. +- **Synced visuals from the shared clock.** Effects animate off elapsed time ([Β§ Effects](#effects)), so feeding them the leader's synced clock instead of each device's local one makes a wall of controllers animate in lockstep, regardless of each one's frame rate. This is the light-domain payoff of the core clock sync. +- **Light distribution**: one device sending rendered light data to another uses the existing ArtNet / E1.31 / DDP standards (the ArtNet *driver* already sends to fixtures today; device-to-device distribution as a sync topology is the part not yet wired). No bespoke protocol. # Web UI diff --git a/docs/assets/reference/mhc-wled-esp32-p4-shield-gpio-terminal-map.png b/docs/assets/reference/mhc-wled-esp32-p4-shield-gpio-terminal-map.png new file mode 100644 index 00000000..33e87abf Binary files /dev/null and b/docs/assets/reference/mhc-wled-esp32-p4-shield-gpio-terminal-map.png differ diff --git a/docs/assets/reference/mhc-wled-esp32-p4-shield-inout-header.png b/docs/assets/reference/mhc-wled-esp32-p4-shield-inout-header.png new file mode 100644 index 00000000..7387f00d Binary files /dev/null and b/docs/assets/reference/mhc-wled-esp32-p4-shield-inout-header.png differ diff --git a/docs/assets/reference/mhc-wled-esp32-p4-shield-pinout.svg b/docs/assets/reference/mhc-wled-esp32-p4-shield-pinout.svg deleted file mode 100644 index be0da781..00000000 --- a/docs/assets/reference/mhc-wled-esp32-p4-shield-pinout.svg +++ /dev/null @@ -1,125 +0,0 @@ - - - - - MHC-WLED ESP32-P4 shield (V2 / v1.0) β€” terminal pinout - read from the board silkscreen Β· GPIO numbers are the ESP32-P4 pins - - - 12x outputs β€” level-shifted, single-ended (LED data) - - - - - - - - 21 - 20 - 25 - 5 - 7IΒ²C SCLΒ·SDA - 23 - 8 - - - - - - - - 27 - 3 - 22 - 24 - 4 - GND - - - amber outline = same GPIO also wired to an RS-485 channel (below) - - - 4x RS-485 β€” each channel = 2 screw terminals (A + B), a differential transceiver pair, NOT a bare GPIO - - - - - GPIO 4 - A - B - - - GPIO 22 - A - B - - - GPIO 24 - A - B - - - GPIO 3 - A - B - - - - - - - loopback: A4 β†’ A3 (amber) Β· B4 β†’ B3 (blue) β€” straight Aβ†’A, Bβ†’B, never crossed - - - 4x in/out β€” diode + ~16 kHz low-pass (button inputs), NOT for a WS2812 loopback - - - 46 - 47 - 2 - 48 - GND - In5V - Out3V3 - - - - Ethernet (RMII, ext clock) - - - MDC 31 Β· MDIO 52 Β· RST 51 Β· CLK 50 (ext-in) Β· PHYaddr 1 - ethType 2 (IP101) Β· clock external-in - - - - Line-In audio (PCM1808 β†’ IΒ²S) - - - 32SCK - 26WS - 33SD - 36MCLK - - - - - Loopback test (RS-485 path, per shield builder) - Tx = GPIO 4 (A-4-B) β†’ Rx = GPIO 3 (A-3-B). Two cables: - A4β†’A3 and B4β†’B3 (straight, never crossed). No switch β€” - GPIO 3's direction is firmware-set. Through 2 transceivers, - so a fail is a transceiver-bandwidth limit, not firmware. - diff --git a/docs/assets/reference/mhc-wled-esp32-p4-shield-rs485-gpio3-switchable-schematic.png b/docs/assets/reference/mhc-wled-esp32-p4-shield-rs485-gpio3-switchable-schematic.png new file mode 100644 index 00000000..f6a3dc93 Binary files /dev/null and b/docs/assets/reference/mhc-wled-esp32-p4-shield-rs485-gpio3-switchable-schematic.png differ diff --git a/docs/assets/reference/mhc-wled-esp32-p4-shield-rs485-loopback-wiring.png b/docs/assets/reference/mhc-wled-esp32-p4-shield-rs485-loopback-wiring.png new file mode 100644 index 00000000..18821836 Binary files /dev/null and b/docs/assets/reference/mhc-wled-esp32-p4-shield-rs485-loopback-wiring.png differ diff --git a/docs/assets/reference/mhc-wled-esp32-p4-shield-rs485-transmit-schematic.png b/docs/assets/reference/mhc-wled-esp32-p4-shield-rs485-transmit-schematic.png new file mode 100644 index 00000000..ba7d6b38 Binary files /dev/null and b/docs/assets/reference/mhc-wled-esp32-p4-shield-rs485-transmit-schematic.png differ diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index 1cfb3028..83f41bc6 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -493,3 +493,15 @@ Four 🟠 Major boundary findings from the PR #29 review are real but each is it - **Core includes platform, compiled core in `mm_core`.** `src/core/moonlive/MoonLive.cpp` `#include`s `platform/platform.h` and calls the exec-memory API directly, and the root `CMakeLists.txt` compiles `MoonLive.cpp`/`MoonLiveCompiler.cpp` into `mm_core` and links `mm_core β†’ mm_platform` β€” violating the header-only-core / no-platform-includes contract both files declare. The runtime exec-memory placement layer wants a core-neutral injected interface (or to move out of `src/core`), so the compiled/platform-dependent surface sits behind `mm_platform` and `mm_core` stays INTERFACE-only. These two are one change (same boundary). - **W^X disabled in the board default.** `esp32/sdkconfig.defaults.esp32s3-n16r8` turns off `CONFIG_ESP_SYSTEM_MEMPROT_FEATURE` and enables `CONFIG_HEAP_HAS_EXEC_HEAP` for *every* build on that board, even with no MoonLive effect installed. The JIT genuinely needs a writable-then-executable heap, but that belongs in a dedicated MoonLive/JIT opt-in overlay or an explicit build profile, not the board default β€” so a stock build keeps memory protection on. - **A scenario rides timing + network.** `test/scenarios/light/scenario_modifier_chain.json` carries `tick_us` baselines (host-performance dependent) and routes a modifier-chain-composition test through `NetworkSendDriver` (pulls network-path behavior into a test that is not about the network). It wants an in-process sink and structural assertions so it stays hermetic, per the `test/**` "no timing or network dependence" rule. + +## MoonI80 prime-only ring: no stall backstop (sibling-path gap) + +**Found:** πŸ‘Ύ Reviewer, pre-commit on the whole-frame stall fix (2026-07-22). + +The MoonI80 wait-timeout backstop (`moonI80Ws2812Wait`, `platform_esp32_moon_i80.cpp`) now recovers a lost/coalesced EOF on **two** of the driver's three completion paths: the lapping ring (`nSlices > ringBufs`, oracle-gated) and the whole-frame path (`!isRing`). The **prime-only ring** (`isRing && nSlices <= ringBufs`) falls through both conditions, so a lost terminator-EOF there leaves `busy` stuck true with no recovery β€” and every prime/arm/transmit-ring path refuses under `busy`, the same permanent-wedge class the whole-frame fix just closed. + +Mitigated in practice: prime-only fires exactly one EOF per frame (no intra-frame coalescing), so the lost-EOF trigger is far rarer than on the whole-frame or lapping paths. But it is the same defect, and per the CLAUDE.md sibling-path rule (a cross-cutting recovery that core owns for one path should cover the sibling path, not be re-implemented per case) the backstop should extend to it rather than leave a third path uncovered. + +**Fix:** widen the backstop's condition so a stuck prime-only ring finalizes too β€” likely a single "any ring frame whose wire time has elapsed with `busy` still set" oracle that subsumes both ring branches, calling the shared `finalizeStalledTransfer`. Verify on the expander wall (prime-only = the small-strand ring config), since the ring recovery is not desktop-testable. + +**Related (same file, same wall dependency):** the whole-frame backstop stops the hardware before draining the FIFO and the EOF ISR drops a firing against an empty FIFO, which rejects a late EOF that arrives after recovery. A tighter guarantee β€” an ISR that has *already passed* the empty-FIFO guard cannot then clear a freshly-recovered `busy` or give `wireFree` for the next transfer β€” would need explicit serialization (disable the EOF interrupt across finalize+rearm, or an explicit recovery/rearm state the ISR checks). The window is extremely narrow (an ISR in-flight at the instant of finalize) and stopping GDMA+LCD first already disables the source; the hardened version is an ISR-concurrency change that must be proven on the wall, not landed blind. diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md index 3c7c6b00..8730a79f 100644 --- a/docs/backlog/backlog-light.md +++ b/docs/backlog/backlog-light.md @@ -32,6 +32,10 @@ The bandwidth arithmetic (datasheet-derived): DMA demand = bus-bytes Γ— pclk. Di A cosmetic residual left after the rebuild-wedge fix (below): on boot, and for a beat after any shift-ring rebuild, the driver shows **"output stalled"** even though `wireUs` reports live completions β€” the *first* frame after a fresh ring build occasionally misses its completion window and trips the dead-frame give-up before the ring settles, so the stale error latches until the next interaction clears it. It is NOT the old wedge (that stayed dead until reboot; this self-clears on any control edit and the ring is genuinely driving underneath). Two clean fixes to weigh: (a) don't count the very first frame after a rebuild toward `deadFrames_` (give the fresh ring one grace frame), or (b) have the give-up retry re-derive status the moment `wireUs` shows a real completion. Low priority β€” the LEDs drive correctly; only the status string is briefly wrong. +### MoonI80 whole-frame async double-buffer corrupts output on the ESP32-S31 (2026-07-23) + +`doubleBuffer` (default on) runs the async deferred-wait path (`tickAsync`): encode frame N+1 into buf[1] while the GDMA clocks frame N out of buf[0], costing `max(encode, wire)` per tick instead of `encode + wire`. On the **MoonI80** backend (our own GDMA below esp_lcd) this is a genuine speed win and is **clean on the S3 and P4** β€” bench-verified on a P4 driving a 64-light strip on Parlio-adjacent pins, and it's what lifted the P4 whole-board rate 48β†’76 fps. **On the ESP32-S31 it flickers** (corrupted/torn frames), on **any** pin β€” bench-isolated on GPIO 60 *and* GPIO 42, so it is NOT pin 60 (the onboard WS2812) and NOT the ring (the ring path is unreached here: expander off β†’ `wantsRing()` false β†’ whole-frame). Turning `doubleBuffer` OFF (single-buffer `tickSync`) is clean on the S31. So the fault is **chip-specific**: MoonI80's hand-rolled async alternation reprograms the GDMA link list per frame and relies on GDMA EOF-in-start-order semantics + a fixed DMAβ†’FIFO settle delay (`esp_rom_delay_us` before `lcd_ll_start`), both tuned on S3/P4; the S31 is a newer RISC-V chip whose GDMA/LCD_CAM revision evidently differs (EOF timing or settle requirement), so the async path displays a still-in-flight or half-encoded buffer. **This is bench-verification territory** β€” do NOT "fix" it from code reading (the whole GDMA/ring subsystem is bench-proven and code-read corrections to it have been wrong before); it needs a logic-analyzer / GDMA-underrun-counter pass on the S31 to find the exact EOF-timing or settle-delay difference. **Decision (PO, 2026-07-23):** leave `doubleBuffer` default-on (it is a real, correct win on i80 / Parlio / MoonI80-on-S3/P4 β€” confirmed all three peripherals genuinely honor the second buffer, NOT i80-only), and backlog this. Two fixes to weigh when picked up: (a) **chip-gate** β€” MoonI80's whole-frame `busInit` declines buf[1] on the S31 only (`if constexpr (platform::isEsp32S31)` β†’ falls to clean `tickSync`), preserving the async win everywhere it works and losing nothing on the S31 (async never worked there); or (b) **root-cause** the S31 GDMA timing so async works there too (recovers the S31 speed, more bench work). Workaround today: on the S31, MoonI80 with `doubleBuffer` OFF, or use `i80`/`Parlio` (both clean with double-buffer on the S31). + ### Graceful blank-on-stall β€” a stalled bus should DARKEN the strip, not leave it lit with garbage (2026-07-15) When the bus stalls mid-frame the WS2812 strip is left holding **random / max-brightness lights** (often all-white β€” the all-ones failure pattern) that only a **power cycle** clears. That is a robustness gap: WS2812s latch their last received color and hold it until re-clocked or power-cycled, so a frame that dies mid-stream leaves every light past the failure point stuck bright. The give-up guard today stops *spending the render thread* on a dead bus (correct) but does nothing about the *strip's* state, so the user sees a wall of garbage LEDs and reaches for the plug. @@ -91,7 +95,7 @@ Do it as its own increment. The multi-destination unicast it builds on has shipp projectMM already speaks DMX **over the network** (Art-Net / sACN via `NetworkReceiveEffect`). The missing half is **wired DMX-512 out**: driving DMX fixtures (moving heads, par cans, wired pixel controllers) directly over an RS-485 differential pair, which is what the RS-485 hardware on carrier boards like the [MHC-WLED ESP32-P4 shield](../reference/mhc-wled-esp32-p4-shield.md) is *for*. DMX-512 is a 250 kbps async serial frame (a break + mark-after-break + 513 bytes: start code + 512 channels) shipped over RS-485 β€” the textbook fixture-control transport. A DMX driver would map the light buffer (or a fixture/attribute model β€” see the [Fixture model β€” moving heads, beams](#fixture-model-moving-heads-beams-long-term) item below) to DMX channels and clock the frame out a UART in RS-485 mode. **What it needs that we don't have yet:** -- **A `platform::` UART-RS485 seam.** The ESP32 UART has a hardware RS-485 half-duplex mode (`uart_set_mode(UART_MODE_RS485_HALF_DUPLEX)`) that auto-drives the transceiver's **DE/RE** (driver-enable / receiver-enable) line β€” the thing our current pin handling has no concept of (we drive pins as plain GPIO). A DMX driver is where DE/RE control first earns its place. The bench insight that surfaced this: the P4-shield loopback couldn't work partly *because* we have no DE/RE handling to flip its RS-485 transceivers Tx↔Rx. +- **A `platform::` UART-RS485 seam.** The ESP32 UART has a hardware RS-485 half-duplex mode (`uart_set_mode(UART_MODE_RS485_HALF_DUPLEX)`) that auto-drives the transceiver's **DE/RE** (driver-enable / receiver-enable) line β€” the thing our current pin handling has no concept of (we drive pins as plain GPIO). A DMX driver is where DE/RE control first earns its place, and only for a **bidirectional** channel: firmware DE/RE toggling is what lets one channel switch Tx↔Rx without a hardware switch. A **fixed-transmit** channel needs none β€” its transceiver is hard-wired to drive. On the [MHC-WLED ESP32-P4 shield](../reference/mhc-wled-esp32-p4-shield.md) that split is physical: GPIO 4, 22, 24 are fixed-transmit (no DE/RE control wanted), and only the switchable GPIO 3 channel is bidirectional β€” the shield handles it with a *mechanical* slide switch (which is how its loopback works). Firmware DE/RE control is what a board would need to make a channel bidirectional *without* such a switch. - **The DMX frame timing** β€” the break/MAB is generated by a baud-rate switch or a GPIO toggle around the UART frame; standard, host-testable as an encoder. - **A fixture/channel-mapping model** β€” trivial for a dumb pixel-per-channel strip, real work for typed fixtures (pairs with the moving-head fixture-model item; a wired-DMX driver and a network-DMX(Art-Net) input would share that fixture model). diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index 7876a95d..cd9d1895 100644 --- a/docs/gettingstarted.md +++ b/docs/gettingstarted.md @@ -19,16 +19,16 @@ support). --- -# Chapter 1 β€” Install projectMM +## Chapter 1 β€” Install projectMM -## 1. Open the installer and plug in +### 1. Open the installer and plug in Open the **[web installer](https://moonmodules.org/projectMM/install/)** in Chrome or Edge, then plug your ESP32 into a USB port. ![The web installer](assets/gettingstarted/01-01-installer-start.png) -## 2. Pick the USB port +### 2. Pick the USB port Click **USB Port β†’ Pick a port…**. Your browser shows a small list of connected devices β€” choose the one that appeared when you plugged in the ESP32. (Not sure @@ -48,7 +48,7 @@ devices match it, so you know you're on the right track before you pick one. ![Port selected, chip detected](assets/gettingstarted/01-03-port-selected.png) -## 3. Pick your device +### 3. Pick your device Choose your device from the **Device** picker. Each card shows a picture, the chip, and what the device can do (LEDs, WiFi, a button, a microphone…); click @@ -85,7 +85,7 @@ Leave **Release** and **Firmware** at their suggested values (the newest stable build, and the firmware that matches your device). Tick **Erase chip first** only if you're starting clean or switching firmware. -## 4. Click Install +### 4. Click Install The installer erases (if you asked it to) and writes the firmware. Just watch β€” it takes under a minute. @@ -93,7 +93,7 @@ it takes under a minute. ![Erasing](assets/gettingstarted/01-07-erasing.png) ![Installing](assets/gettingstarted/01-08-installing.png) -## 5. Get it on your network +### 5. Get it on your network What happens next depends on your device: @@ -104,7 +104,7 @@ What happens next depends on your device: - **Ethernet:** plug in the cable β€” it connects on its own, no password needed. -## 6. Open your device +### 6. Open your device When it's online, the installer shows a link β€” your device's address on your network. Click it. @@ -122,14 +122,14 @@ device's own web interface, served straight from the ESP32. Let's look around. --- -# Chapter 2 β€” Your projectMM interface +## Chapter 2 β€” Your projectMM interface Everything below runs **in your browser, live from the device**. There's no app, no account, no cloud β€” the ESP32 itself serves this page, and every change you make takes effect on the lights immediately. Open the link from step 6 and follow along; you can't break anything by exploring. -## The layout: list, preview, controls +### The layout: list, preview, controls ![The full projectMM interface](assets/gettingstarted/02-01-UI-large.png) @@ -162,7 +162,7 @@ phone, standing next to your lights: ![Small width β€” single column](assets/gettingstarted/02-03-UI-small.png) -## The 3D preview +### The 3D preview ![The 3D preview, lights numbered](assets/gettingstarted/02-04-UI-Preview.png) @@ -175,7 +175,7 @@ updates, then fewer points) on a slow connection rather than stalling. > More on how the preview streams from the device: > [PreviewDriver](moonmodules/light/moxygen/PreviewDriver.md). -## The system modules +### The system modules The top of the list is your device's "about" section β€” read-outs and connection settings. You rarely need to touch these, but they're the first place to look if @@ -199,6 +199,8 @@ USB cable needed once it's on your network. ![The Firmware module](assets/gettingstarted/02-06-UI-Firmware.png) +**Updating from an older build?** Skim the [migration notes](MIGRATING.md) first. Most updates need nothing β€” the device keeps your settings β€” but a breaking change is listed there with the one action it costs you (usually re-setting or re-adding a control). + > [FirmwareUpdateModule](moonmodules/core/system.md#firmware-update) **Network** β€” your connection: WiFi or Ethernet, signal strength, and the @@ -218,7 +220,41 @@ other. > is an example, driving its LEDs with [FastLED](https://github.com/FastLED/FastLED) (on > hold until projectMM ships as a reusable library). -## Building a light show: layouts β†’ layers β†’ drivers +### Control it from your phone with WLED Native + +The device's own web UI works on a phone, but for quick on/off and brightness from +your pocket there's a nicer option: **WLED Native**, the open-source mobile app for +the WLED ecosystem. projectMM speaks the WLED JSON API and announces itself over the +network the same way a WLED device does, so the app finds your projectMM controllers +automatically β€” no setup, no pairing. Each one shows up as a card with a power toggle +and a brightness slider, so a roomful of controllers is a scroll and a tap away. + +![projectMM devices discovered in WLED Native](assets/core/WLED%20Native%20discovers%20projectMM.jpeg){ width="300" } + +Get it free for your phone: + +- **iPhone / iPad:** [WLED Native on the App Store](https://apps.apple.com/us/app/wled-native/id6446207239) +- **Android:** [WLED Native on Google Play](https://play.google.com/store/apps/details?id=ca.cgagnier.wlednativeandroid) + +For the full picture and controls, the device's web interface is always there at +`http://.local` β€” WLED Native is the fast everyday remote alongside it. + +### Bring it into your smart home with Home Assistant + +Want your lights in the same dashboard as the rest of your house β€” and in +automations, voice assistants, and Apple Home? projectMM adopts into **Home +Assistant** like any other light: point the device at your HA setup and it appears +as a light entity with on/off and brightness, alongside a floor of other devices. + +![projectMM devices as lights in a Home Assistant dashboard](assets/core/ha-integration.png){ width="600" } + +There are two ways in β€” zeroconf (HA finds the device on its own) or MQTT +auto-discovery (for a broker-only or cross-subnet setup) β€” and from there you can +bridge the entity into Apple Home too. The step-by-step, including installing HA +and the MQTT broker if you don't have them, is in the +[home automation guide](usecases/home-automation.md). + +### Building a light show: layouts β†’ layers β†’ drivers The bottom three modules are where the fun is. They form a simple pipeline: a **layout** says where your lights are, **layers** decide what colours play on @@ -257,7 +293,7 @@ keep going. --- -## Where to go next +### Where to go next - **Understand the pipeline** β€” how layouts, layers, effects, modifiers and drivers fit together: [architecture overview](architecture.md#the-pipeline). diff --git a/docs/history/lessons.md b/docs/history/lessons.md index 8c928173..0adc67cb 100644 --- a/docs/history/lessons.md +++ b/docs/history/lessons.md @@ -105,6 +105,20 @@ DemoReel hosts a child effect and needs its `dimensions()` to extrude it. The fi The harder lesson is verification: the agent's hardware test asserted the Layer **status string** ("16Γ—16Γ—1"), which was correct, while the *render* (driven by the corrupted LUT) was black; the product owner caught it by eye. A correctness test for a mapping/effect must assert the **buffer or LUT is non-empty with expected coverage** (the regression counts LUT destinations == physical light count), not just the declared dimensions. A width-sensitive bug is invisible on the uint32 desktop build; such paths need a uint16-typed unit test or hardware confirmation. +## Lessons from the parallel-LED-driver consolidation branch + +The consolidation (three CRTP driver classes β†’ one `ParallelLedDriver` selecting a `LedPeripheral` backend at runtime) surfaced four robustness bugs β€” most on the bench, where a desktop test could not reach them β€” each about a *cross-cutting rule reaching the wrong object*, and all found only because a driver's state lives on a swappable backend now. + +- **A per-parent `quiesce()` misses a worker that walks the WHOLE tree.** Replacing a **layout** on a split-render device (the core-1 encode worker running) was a LoadProhibited use-after-free. Core already had a "stop the worker before a structural mutation" rule β€” `MoonModule::removeChild`/`replaceChildAt` call `this->quiesce()` β€” but that only stops a worker the *mutated node's parent* owns. The encode worker is owned by `Drivers`, yet it ticks the drivers and a driver walks the entire layout/layer tree (`PreviewDriver::sendFrame β†’ Layouts::forEachCoord`), so freeing a node in a *sibling* subtree (a layout) never quiesced it. The lesson: when a worker reads **across** subtrees, quiescing the mutated node's own parent is not enough β€” the guard must reach the worker wherever it lives. Fix: a core `quiesceForMutation()` that also fires a `quiesceRenderHook_` (a function-pointer seam mirroring `setSchemaChangedHook`), wired once in `main.cpp` to `Drivers::quiesceRenderSplit()`, so core stays domain-neutral. And the completeness trap: the first fix covered `add`/`remove`/`replace` but missed the **fourth** mutator, `moveChildTo` (the drag-reorder UI) β€” a cross-cutting rule lifted into core must cover *every* path, not three of four. (`unit_Drivers_rendersplit` pins both a layout mutation and a reorder through the real worker thread; each is verified greenβ†’red.) + +- **A control whose backing variable moves between objects is lost on reload unless persistence re-binds first.** After a watchdog reboot the giant wall's `clockPin` (and the whole MoonI80 ring cluster) reverted to their defaults β€” a reboot silently changing a control, which should never happen. Root cause: those controls live on the *peripheral backend* object, and which backend is live depends on the `peripheral` control's value. On reload `FilesystemModule::applyNode` overlaid the saved values in list order: `peripheral` got written but did **not** swap the live backend, so `clockPin` was written to the *default* backend's member β€” then the later swap to the saved peripheral discarded that backend, reverting clockPin to its constructor default. The lesson: when a module's **control set depends on one of its own control values**, a single overlay pass writes the value-dependent controls onto the wrong (about-to-be-replaced) objects. Fix: overlay β†’ `rebuildControls()` (which re-runs `defineControls`, swapping the live backend to match the just-applied `peripheral` and re-binding the list to the *right* members) β†’ overlay again. General (any value-dependent control set), gated by `rebuildControls`'s schema-hash so it no-ops for ordinary modules, and the second overlay is idempotent. Invisible on desktop (no real backends link, so no swap); found only on a MoonI80 board whose persisted peripheral differs from the constructor default. (`unit_FilesystemModule_persistence` pins it with a value-dependent mock, verified greenβ†’red.) + +- **A `break` on the first unresolvable persisted child dropped every module after it.** A user's driver "spontaneously" vanished on reboot. Cause: `FilesystemModule::applyNode` reconciled saved children positionally (`.type` must match live child `i`) and `break`ed the whole loop on the first entry it couldn't place β€” either a code-wired sibling whose boot order differed from the saved order, or (the real trigger) a **renamed/removed type** (this device's file still held pre-consolidation `MoonLedDriver`/`MultiPinLedDriver` entries). The `break` then dropped every *later* JSON child, so one dead entry took out the user's real modules recorded after it. The lesson: a positional reconciler must be **fault-isolating** β€” a single un-placeable entry skips itself and keeps going, never aborts the tail. Fix: decouple the JSON index from the live position (`i` vs `pos`) β€” an entry that produces no live child (`ModuleFactory::create` returns null, or a code-wired child sits in a stale slot) is skipped without advancing `pos`, so the file's later user modules still map to the right index. This also makes the documented ADR-0013 "unknown type drops, the rest stay" behavior actually hold. User-module order still round-trips (user modules are created fresh in file order; only code-wired singletons β€” whose order is cosmetic β€” may reorder, self-correcting on the next save). Invisible until a container gained a *second* code-wired child (before that there was never an order mismatch) AND a device carried a renamed type. (`unit_FilesystemModule_persistence` pins both the reordered-code-wired-siblings case and a user-reorder round-trip, verified greenβ†’red.) + +- **A live control-swap that frees a resource a worker reads needs the same quiesce as a tree mutation.** The `peripheral` Select frees the old bus backend (`delete peripheral_`) on switch, and the core-1 encode worker dereferences that backend (`busBuffer`/`busTransmit`) β€” so a live swap during the render split is a use-after-free, the same class as the tree-mutation crash, but it does NOT pass through `MoonModule::quiesceForMutation` (it is not a child-array mutation). `deinit()` drains the bus DMA but not the worker thread. The lesson: the "stop the worker before freeing what it reads" rule is not only about child-array mutations β€” any live free/reuse of worker-visible state needs it. Fix: fire the same render-worker hook (`MoonModule::notifyQuiesceRender()`, the public sibling of `notifySchemaChanged()`) at the top of `swapPeripheral`. Caught by the pre-merge whole-branch review, not the per-commit reviews β€” the swap and the mutation-quiesce fix are in different commits, and only the cumulative view sees "the branch added a quiesce rule but left one live-free path uncovered." (`unit_ParallelLedDriver_swap` pins that the hook fires before the backend is freed, verified greenβ†’red.) + +- **MoonI80's whole-frame double-buffer wedged the bus after ~2 frames; the fix was to NOT double-buffer that path, not to patch the race.** Switching to the MoonI80 peripheral with `doubleBuffer` on froze the LEDs at ~200 ms/frame (5 fps) after a couple of clean frames, recovering the instant `doubleBuffer` was turned off. Root cause: MoonI80's own-GDMA whole-frame path serialized two in-flight buffers with a hand-rolled handshake β€” a **binary `wireFree` semaphore the EOF ISR gives unconditionally, plus a blind non-blocking pre-drain in `busTransmit`**. A give-when-already-1 is lost (binary), and the blind `xSemaphoreTake(wireFree, 0)` can swallow the very completion the next blocking wait then waits for, so every subsequent frame times out at the `kWireFreeTimeoutMs = 200` backstop β€” deterministic freeze, not a flake. i80/Parlio never had it: they route through a real transaction queue (esp_lcd / the Parlio driver), so a second transfer is absorbed for them. The lesson: **for MoonI80, whole-frame double-buffering overlaps ~150 Β΅s of encode with a ~2 ms wire β€” a ~7% win not worth a freeze-prone concurrent handshake; its real speed is the streaming ring, not two whole-frame buffers.** So the fix subtracts rather than patches: a `LedPeripheral::supportsDoubleBuffer()` seam (default true; MoonI80 false), the orchestrator gates the second-buffer request on it, and the `doubleBuffer` control hides on a peripheral that can't run it (computed *after* the peripheral swap, or an i80β†’MoonI80β†’i80 cycle leaves the control stuck hidden). Method note: the freeze was reproduced deterministically on the bench (works-then-wedges-forever is traceable, unlike a flake), but the serial-open reset on native-USB S3 boards defeated in-place instrumentation β€” the decisive evidence came from the `tick:` line over HTTP-triggered state, and from measuring that double-buffer buys ~1 ms on a small frame (so removing it costs almost nothing). (`unit_ParallelLedDriver_doublebuffer` pins that a `supportsDoubleBuffer()==false` peripheral stays single-buffer with the toggle on, greenβ†’red; `scenario_peripheral_switch` guards the freeze live β€” a ~200 ms tick after switching to MoonI80 with double-buffer on fails it.) + ## Lessons from the repo-transfer + v1.0.0 release branch Moving `ewowi/projectMM β†’ MoonModules/projectMM` and cutting v1.0.0 surfaced three CI failures from the *infrastructure around* the release, not the diff. diff --git a/docs/history/plans/Plan-20260722 - Release 4 scope - effect breadth + rename runway.md b/docs/history/plans/Plan-20260722 - Release 4 scope - effect breadth + rename runway.md new file mode 100644 index 00000000..ac6033af --- /dev/null +++ b/docs/history/plans/Plan-20260722 - Release 4 scope - effect breadth + rename runway.md @@ -0,0 +1,44 @@ +# Plan β€” Release 4 scope: effect breadth + the rename runway + +## Context + +Release 3 is being cut now. This plan captures the **Release 4** candidates β€” the next strategic thread after R3 β€” so the direction is recorded before the work starts. The product owner's steer: the items below are R4, not R3. + +The backlog has one dominant strategic thread that most other items orbit: the **projectMM β†’ MoonLight rename** ([backlog rename plan](../../backlog/rename-to-moonlight.md)). Its gate is *"the effect library must not feel thin next to the predecessor's 60+ effects."* Two in-flight plans feed that gate, and R4 is where they land. The shape of R4 is therefore **"the effects release + the rename runway"**: grow visible feature breadth while moving the single most important strategic gate (rename readiness), and leave the hardware-verification-bound driver work to its own dedicated push. + +This is a roadmap/scope plan, not a single-feature `/plan`. Each item below gets its own `/plan` + commit when reached; this document is the *map* and the *why*. + +## The spine β€” effect-breadth parity (headline) + +**MoonLight migration, Stage 1 + the next effect batch.** ([Plan-20260630 - MoonLight migration (multi-stage)](Plan-20260630%20-%20MoonLight%20migration%20(multi-stage).md).) + +This is the biggest lever and the explicit *"execution vehicle for the effect-breadth parity gate."* ~21 of the predecessor's 60+ effects are ported. Stage 1's prerequisites are the highest-value core work available, because every future effect leans on them: + +- **Shared palette** β€” hard prerequisite; many effects color via `ColorFromPalette`. Generalize the pattern `PlasmaPaletteEffect` hard-codes today. +- **The shared primitive library** β€” FastLED-named, our own implementation, hot-path-tuned integer-only: `beatsin8`, `inoise8`, `qadd8`, `nscale8`, `random8`/`random16`, `ColorFromPalette`, and the dimension-agnostic draw set. Extends the existing `color.h` (`scale8`, `sin8`). +- **Tag/emoji legend** β€” settle before batch-migrating so every module is consistent from batch one. +- **Per-library doc model** β€” `effects_.md` compact table rows (per [ADR 0015](../../adr/0015-library-is-a-tag-not-a-folder.md)); changes the `check_specs.py` contract. + +Then the next migration batch on top. This is the R4 headline: it unblocks the rename *and* is pure user-visible feature growth. + +## Two quick wins β€” scoped and ready + +- **Active-instance election primitive.** ([Plan-20260710 - Active-instance election primitive](Plan-20260710%20-%20Active-instance%20election%20primitive.md).) A core `ActiveInstance` that removes duplicated singleton-election bookkeeping from `AudioService` + `DevicesModule` (both had real dangling-static bugs). Textbook *Complexity-lives-in-core* subtraction; small; in flight. +- **CodeRabbit #29 boundary findings (4).** ([backlog-core Β§ MoonLive core/platform layering](../../backlog/backlog-core.md#moonlive-coreplatform-layering--jit-sdkconfig-scoping-coderabbit-29-4-findings).) MoonLive core-includes-platform + compiled-into-`mm_core`, W^X disabled in the board default, a scenario riding timing + network. Real, already scoped; good hygiene to close before a named release. + +## The RS-485 / DMX-512 opportunity (candidate, larger) + +The [P4-shield RS-485/DMX hardware is now well documented](../../reference/mhc-wled-esp32-p4-shield.md) (the builder's schematics landed 2026-07-16). The **RS-485 / DMX-512 wired-output driver** + its **`platform::` UART-RS485 seam** ([backlog-light](../../backlog/backlog-light.md#rs-485-dmx-512-wired-output-future-the-physical-dmx-driver)) is demand-driven and self-contained. It is a meaty new capability β€” a flagship candidate if R4 wants a headline new-hardware feature alongside the effects work, but it is larger than the two quick wins and should be its own `/plan`. + +## High-light-count driver work (in R4 β€” hardware-verified) + +The streaming-ring / lane-driver work is **in R4**. It is hardware-verification-heavy β€” each item needs the expander wall (and the relevant board) to prove, so these land with bench sign-off, not blind: + +- **Classic-ESP32 shift-register ring on raw I2S** ([backlog-light "WANTED"](../../backlog/backlog-light.md#drivers)) β€” the high-light-count classic driver. +- **P4 Parlio streaming ring** ([backlog-light "WANTED"](../../backlog/backlog-light.md#drivers)) β€” lift the P4 Parlio ceiling past ~21K to light-count-independent. +- **Shared lane-driver scaffolding** β€” extract when the 3rd parallel backend lands (deferred until then, but that 3rd backend is one of the two above). +- **MoonI80 prime-only ring stall backstop** ([backlog-core](../../backlog/backlog-core.md#mooni80-prime-only-ring-no-stall-backstop-sibling-path-gap)) + the whole-frame late-EOF serialization hardening β€” the sibling-path recovery gaps; verify on the expander wall. + +## Success shape + +R4 ships when: the migration Stage-1 primitives + the next effect batch have landed (moving the rename's breadth gate forward), the `ActiveInstance` primitive and the CodeRabbit #29 boundary fixes are in, the RS-485/DMX driver reaches a verified first output, and the high-light-count driver work above is bench-verified. The rename itself is a *separate* cutover (its own plan); R4 is the runway that makes the name not a downgrade, not the switch. diff --git a/docs/history/plans/Plan-20260723 - Consolidate parallel LED drivers into one module + peripheral strategy.md b/docs/history/plans/Plan-20260723 - Consolidate parallel LED drivers into one module + peripheral strategy.md new file mode 100644 index 00000000..0f931fa7 --- /dev/null +++ b/docs/history/plans/Plan-20260723 - Consolidate parallel LED drivers into one module + peripheral strategy.md @@ -0,0 +1,59 @@ +# Plan: Consolidate the parallel LED drivers into one module + a peripheral strategy + +## Context + +The parallel-LED output today is **four classes**: a CRTP base `ParallelLedDriver` (1816 lines, all shared logic) and three concrete CRTP subclasses, each a full `MoonModule`: `MoonLedDriver` (703 lines, own-GDMA + streaming ring + 74HCT595 expander, LCD_CAM only), `MultiPinLedDriver` (256 lines, esp_lcd i80 on S3/P4 LCD_CAM + classic I2S), `ParlioLedDriver` (100 lines, P4 Parlio). Each is separately factory-registered, so the UI add-module picker offers all three on **every** board β€” including ones that can't run them (`lanesAvailable()==0`). + +The product owner wants **one** user-facing "Parallel LED" module with a **peripheral dropdown** that surfaces the shared controls plus the selected peripheral's unique controls, allocating only the selected backend. + +**The key realization:** the CRTP base exists *only* to reach the peripheral via compile-time dispatch β€” every `derived()->` call is a peripheral op; there is no non-peripheral use of CRTP. If the peripheral moves behind a **runtime `LedPeripheral*` strategy interface**, CRTP loses its purpose and `ParallelLedDriver` collapses from a template base into **one plain `MoonModule`** that owns controls/lifecycle/tick and holds a `LedPeripheral*`. There is no separate orchestrator to add β€” the ex-base *is* it. + +**Hot path is safe:** every `busX()` call is per-frame or per-reinit, never per-light (the per-light encode operates on the raw `uint8_t*` from `busBuffer()`). One vcall/frame vs ~3500Β΅s of frame work is negligible. CRTP's "no runtime indirection" protected per-*light* calls, which this design does not add. + +**Net effect β€” a subtraction refactor + a feature:** 4 classes β†’ 1 module + 1 interface + 3 stripped backends; one control set, one lifecycle, one registry entry, one UI card; the backends shrink (lose MoonModule/control/lifecycle scaffolding). Plus the one-selectable-card UX. + +**Scope boundary β€” `RmtLedDriver` stays separate (evaluated, deliberate).** RmtLed is `: public DriverBase`, NOT a `ParallelLedDriver<>` subclass. It is a different *shape*: N independent per-pin RMT TX channels + a symbol encoder, versus the parallel family's single lockstep DMA bus + bit-transpose. The genuine overlap (pin/count parsing) is already factored into the shared `PinList.h` helper β€” the correct dedup. Folding RmtLed behind the `LedPeripheral` interface (built around a single DMA bus: `busBuffer`/`busTransmit(i,bytes)`/`busCapacity`/ring/double-buffer) would be a *leaky* abstraction carrying ops half its implementers can't honor β€” an expansion, not a reduction. Two coherent concepts stay two modules. + +**Coexistence & conflict (validated against the code):** +- **Multiple drivers coexist today** β€” `Drivers` accepts N children of role `"driver"`, no singleton, and its docstring describes the multi-driver composite. So RMT + Parlio + LCD_CAM drivers on one P4 already works; the consolidation preserves it (each "Parallel LED" instance owns its own `LedPeripheral*`). +- **The conflict axis is the peripheral BLOCK, not the module type.** i80 and MoonI80 both drive the single **LCD_CAM** block, so two LCD_CAM-family drivers conflict β€” even though post-consolidation they'd be the same module type with different `peripheral` values. **Nothing guards this today** (the pin-uniqueness backlog item covers pins, not peripheral blocks). The consolidation is the natural home for a **"one driver per hardware peripheral block"** claim check (refuse with a clear status) β€” strictly more correct than today. Included as a small guard (Stage 4). + +## Decisions settled with the product owner + +- **One canonical registered name** (`ParallelLedDriver`) + **migrate the catalog**. Rewrite all 7 `deviceModels.json` entries (4 MultiPin, 3 Parlio) to the canonical type + an explicit `peripheral` control value; update `scenario_perf_full.json`; add a `MIGRATING.md` entry (field devices' persisted `MultiPinLedDriver`/`ParlioLedDriver` type won't resolve after the rename β€” the module drops on boot, user re-adds and picks the peripheral). Accepted trade: cleaner end-state, real migration cost. +- The consolidated type name **ends in `LedDriver`** (`check_devices.py` allows a `pins` control only on such types). +- **RmtLed out of scope** (separate family, above). +- **Include the peripheral-block claim guard** (PO chose to add it β€” a real robustness gap the consolidation exposes). + +## Design + +**Stage 1 β€” `src/light/drivers/LedPeripheral.h` (new interface).** Name ends in a neutral noun (not `LedDriver`) so the checkers don't treat it as a module. Pure-virtual required core (`busInit/busDeinit/busBuffer/busCapacity/busTransmit/busWait/busLastTransmitUs/busLoopback`) + const virtual descriptors replacing the 5 statics (`lanesAvailable/supportsPinExpander/powerOfTwoBus/initFailMsg/loopbackFullWidth`) + non-pure virtuals with the existing CRTP defaults for the ring cluster (`busInitRing`β†’false, `busTransmitRing`β†’false, `busIsRing`β†’false, `wantsRing`β†’false, `busRingMode`β†’nullptr, `addRingControls`β†’{}, `refreshBusKpi`β†’{}, `snapHelperReady`β†’false), bus-pin cluster (`addBusControls`/`busControlTriggersBuild`/`recordBusPins`/`extraBusPinsCurrent`/`validateBusPins`/`validateBusFatal`/`clockPinForBus`/`dmaBudgetBytes`). A backend reaches the orchestrator's shared state via a set-once **back-pointer** `attach(ParallelLedDriver* owner)`; promote the handful of needed protected members (`busPinList/busPinCount/laneList/laneCount/latchBit/outChannels/busClockMultiplier/loopbackRxPin`) to public const accessors. `busLoopbackRide` stays on the orchestrator (peripheral-agnostic). + +**Stage 2 β€” the 3 backends.** Each header becomes `: public LedPeripheral` (was `: public ParallelLedDriver`). `busX()` bodies kept verbatim, shared-state reads reroute through `owner_->`. `addBusControls`/`addRingControls` STAY in the backend (peripheral-specific: Moon's ring cluster + expander-gated clockPin; MultiPin's clockPin+dcPin; Parlio empty), taking `ControlList&`. Per-peripheral state moves into the backend object (Moon's handle + ring controls + fork-join helper; MultiPin's handle + clockPin/dcPin; Parlio's handle + kClockHz). A backend keeps its own chip-specific `if constexpr` internally (e.g. MultiPin's `dmaBudgetBytes` classic-i80 branch) β€” only *cross-peripheral* dispatch becomes runtime. + +**Stage 3 β€” de-templatize `ParallelLedDriver`.** `class ParallelLedDriver : public DriverBase`. Every `derived()->busX()` β†’ `peripheral_->busX()`; every `Derived::kX` β†’ `peripheral_->x()`. The 4 `if constexpr (Derived::lanesAvailable()==0) return;` sites (tick/reinit/deinit/loopback) β†’ runtime `if (!peripheral_ || peripheral_->lanesAvailable()==0) return;`. **Null-peripheral guards** on every hot/cold path that deref'd `derived()` (new surface CRTP never needed β€” the `tick()` guard is render-thread-critical). New member `LedPeripheral* peripheral_`. Constructor creates the default backend for this chip so a fresh module is immediately functional. + +**Stage 4 β€” the `peripheral` selector.** A Select whose options are the linked backends filtered to `lanesAvailable()>0`, labels in a stable member array (borrowed-pointer pattern like `presetOptions_`). **The swap ordering trap** (`Scheduler::setControl` runs `rebuildControls()` BEFORE `onControlChanged`): drive control-surfacing off the just-written `peripheralSel_` index, do the object swap (`deinit`+`busDeinit`+`delete` old β†’ `create`+`attach`+`parseConfig`+`reinit` new) in `onControlChanged`, then call `rebuildControls()` once more (schema-hash gate suppresses a redundant resync). Add the peripheral-block claim guard here (refuse a peripheral a sibling driver already holds). + +**Stage 5 β€” compile-time backend SET, runtime SELECTION.** Keep the `CONFIG_SOC_*` gates but at one registration point (`LedPeripheralRegistry`): each backend factory entry wrapped in its `#if` so a classic ESP32 links ONLY the esp_lcd-i80 backend (no MoonI80 ring / Parlio code β€” the 4MB-flash ceiling matters). Selection is runtime over the linked-and-supported subset. + +**Stage 6 β€” registration/catalog/scenario/migrating/docs.** One `registerType("ParallelLedDriver", "light/drivers.md#parallelled")` gated on `#if any-backend-linked`; `RmtLedDriver` untouched. Rewrite the 7 `deviceModels.json` entries + `scenario_perf_full.json` to the canonical type + `peripheral`. `MIGRATING.md` entry (follow the existing 2026-07-16 `I80LedDriver`β†’`MultiPinLedDriver` rename format). `drivers.md`: canonical `` + keep old anchors as aliases; a `### ParallelLedDriver` card documenting the shared controls (check_specs now DISCOVERS ParallelLedDriver as a module β€” no longer a skipped template β€” so its controls MUST be documented); backend moxygen pages still generate and link. + +**Stage 7 β€” tests.** The 3 concrete unit tests (`unit_{MultiPin,Parlio,Moon}LedDriver.cpp`) construct `mm::ParallelLedDriver d` + set the peripheral (add a test-only `setPeripheralForTest(label|ptr)`); shared assertions (slicing/frameBytes/RGBW/loopback-visibility) are orchestrator logic and stay; peripheral-specific ones (dmaBudget, expander gate, clockPin/dcPin, Parlio no-8-rule) run with the matching backend attached. The shared-base mocks (`MockRingDriver : public ParallelLedDriver` in `unit_ParallelLedDriver_{ring,doublebuffer,pinexpander}.cpp`) convert to `MockPeripheral : public LedPeripheral` with the same memory-backed `busX()` bodies. + +**Stage 8 β€” staged commits** (each builds + host-tests green): (1) additive: `LedPeripheral.h` + promote accessors; (2) the atomic collapse β€” de-templatize + convert 3 backends + wire `peripheral_` + convert base-test mocks (CRTPβ†’runtime can't half-exist; validated on host where desktop stubs make backends inert); (3) selector + registry + compile-filter + the `main.cpp` rename; (4) catalog + scenario + MIGRATING + docs (check_devices/check_specs green); (5) re-target the 3 unit tests; (6) cleanup + docs polish. + +## Risks (from the design pass) + +1. **Null-peripheral windows** (construction, the swap) β€” every ex-`derived()` deref needs a `!peripheral_` guard; `tick()`'s is render-thread-critical. +2. **`defineControls` purity vs swap ordering** β€” surface controls off `peripheralSel_`, re-`rebuildControls()` after the swap. +3. **Chip-specific `if constexpr` stays inside backends** (references platform constants); only cross-peripheral dispatch goes runtime. +4. **check_specs discovery flip** β€” ParallelLedDriver becomes a discovered module and MUST document its controls, or the gate fails. +5. One heap alloc/module (the backend) + one vcall/frame β€” negligible; account backend bytes in `driverHeapBytes()` if it holds large buffers (it doesn't β€” DMA pools are platform-owned). + +## Verification + +- Host: `cmake --build build` (zero warnings) + `ctest` + `uv run moondeck/scenario/run_scenario.py` green at each stage; the base-test mock backend exercises the orchestrator on desktop. +- Checkers: `uv run moondeck/check/check_devices.py` (all 7 board types resolve, `pins`-on-`*LedDriver` rule holds) + `uv run moondeck/check/check_specs.py` (docPath `#parallelled` resolves; ParallelLedDriver's controls documented) green after Stage-6. +- ESP32: build classic + S3 + P4 β€” confirm classic links ONLY the esp_lcd-i80 backend (flash size not regressed), S3 offers Moon+MultiPin, P4 offers all three. +- Hardware (product owner, on the bench): on a P4, add a Parallel LED driver, switch the `peripheral` dropdown live (Parlio ↔ i80 ↔ MoonI80) and confirm controls re-surface + LEDs drive per selection; confirm memory drops for the deselected backend; confirm a second driver claiming the same peripheral block is refused with a clear status; confirm RMT + Parlio + i80 coexist. **The dropdown swap and multi-driver coexistence are user-observable β€” PO eyes on the panel are the measurement.** diff --git a/docs/moonmodules/core/supporting.md b/docs/moonmodules/core/supporting.md index 26c5dc7f..22908958 100644 --- a/docs/moonmodules/core/supporting.md +++ b/docs/moonmodules/core/supporting.md @@ -4,7 +4,7 @@ The core machinery the UI modules lean on β€” not directly user-facing, so no co ### Control -A named, typed value a MoonModule exposes to the UI β€” the binding between a class variable and its web-UI widget, DMX channel, and persisted value. Every module's `controls_` is a list of these. +A named, typed value a MoonModule exposes to the UI β€” the binding between a class variable and its web-UI widget, DMX channel, and persisted value. Every module holds a list of these. Detail: [technical](moxygen/Control.md) @@ -20,7 +20,7 @@ Detail: [technical](moxygen/Scheduler.md) ### MoonModule -The base class every module derives from β€” the shared lifecycle (`setup` / `tick` / `release`), the `controls_` list, child propagation, and the self-reporting footprint (`classSize` / `dynamicBytes` / `tickTimeUs`). Learn the pattern once, apply it everywhere. +The base class every module derives from β€” the shared lifecycle (`setup` / `tick` / `release`), the controls list, child propagation, and the self-reporting footprint (`classSize` / `dynamicBytes` / `tickTimeUs`). Learn the pattern once, apply it everywhere. Detail: [technical](moxygen/MoonModule.md) diff --git a/docs/moonmodules/core/system.md b/docs/moonmodules/core/system.md index 8c7ed30e..b566d5cd 100644 --- a/docs/moonmodules/core/system.md +++ b/docs/moonmodules/core/system.md @@ -15,6 +15,7 @@ The device's identity and vitals β€” name (behind mDNS `.local`, the SoftA - `deviceName` β€” the device identity behind mDNS `.local`, the SoftAP SSID, and the DHCP hostname. - `deviceModel` β€” the board model (drives the installer catalog entry). - `expertMode` β€” reveals advanced tuning/diagnostic controls (marked πŸ”§) across the UI; off by default. +- `logLevel` β€” serial verbosity (None/Error/Warn/Info/Debug/Verbose); default Warn silences the periodic tick line but keeps warnings/errors. First 60 s always logs at Info. - read-only vitals β€” `uptime`, `fps`, `heap`, `psram`, `flash`, `chip`, and per-module footprint. Detail: [technical](moxygen/SystemModule.md) diff --git a/docs/moonmodules/light/drivers.md b/docs/moonmodules/light/drivers.md index 4a537875..8a2bb2cd 100644 --- a/docs/moonmodules/light/drivers.md +++ b/docs/moonmodules/light/drivers.md @@ -12,10 +12,10 @@ Several drivers can share one buffer, each driving its own slice. Every driver s Added once by [`DriverBase`](moxygen/DriverBase.md) so no driver re-implements it: a per-driver **output correction** (how this driver's slice looks) and a **source window** (which slice of the shared buffer it reads). Every driver card leads with this block; its own controls follow. -Shared driver controls: localBrightness, preset, whiteMode, start, count +Shared driver controls: localBrightness, lightPreset, whiteMode, start, count - `localBrightness` β€” this driver's dim (0–255), multiplied with the global brightness into one LUT; both sliders reach the output. -- `preset` β€” the [light preset](supporting.md) this driver applies per light (channel order / RGBW synthesis), referenced by its stable id (not its name), so renaming or reordering presets never breaks a driver's reference and it survives a reboot. +- `lightPreset` β€” the [light preset](supporting.md) this driver applies per light (channel order / RGBW synthesis). At runtime the driver holds the preset's stable id, so **reordering** presets never disturbs the reference; the reference **survives a reboot** because the preset's *name* is persisted and re-resolved on load. The one caveat is **renaming**: within a session the id keeps the link, but after a reboot a renamed preset no longer matches the persisted name, so the driver falls back to the default preset β€” re-pick it if you rename a preset a driver uses. - `whiteMode` β€” how the white channel is derived for an RGBW strip, applied only when the referenced preset carries a W channel. - `start` β€” first light of the shared buffer this driver reads (default `0`). - `count` β€” how many lights from `start` this driver drives. **Blank / default drives all lights**; set a number to output only that slice β€” the way multiple drivers each own a section of one buffer (an onboard status LED at `0`, the main strip from `1`). @@ -24,6 +24,7 @@ Detail: [technical](moxygen/DriverBase.md) ## LED drivers + @@ -31,21 +32,36 @@ Detail: [technical](moxygen/DriverBase.md) ### LED driver πŸ’« Β· wire -Addressable WS2812B-class LEDs over a wire. Four drivers, same controls and same wire contract; they differ in how many strands clock out at once and on which chip. **Start with RMT** for a few strands, **MultiPin** for many (up to 16), **Parlio** on a P4 β€” and **Moon** when you need more lights than one DMA buffer holds, or more strands than you have GPIOs (its 74HCT595 pin expander turns 6 pins into 48 strands). Which to pick, and why: [details](#led-driver-details). +Addressable WS2812B-class LEDs over a wire, same controls and same wire contract however the bits reach the pins. Two drivers: **RMT** for a few strands, and **ParallelLedDriver** for many (up to 16) clocked out at once. The parallel driver has a **`peripheral`** control that picks the DMA peripheral, offering only the ones the chip supports: + +- **`i80`** β€” the esp_lcd i80 bus (LCD_CAM on any chip that has it β€” the S3, P4, and S31 β€” the I2S peripheral on the classic ESP32). The general default for many strands. +- **`Parlio`** β€” the Parallel-IO peripheral (P4 and S31). +- **`MoonI80`** β€” our own GDMA below esp_lcd (LCD_CAM chips only β€” S3, P4, S31): a *streaming ring* for more lights than one DMA buffer holds, plus a 74HCT595 **pin expander** that turns 6 pins into 48 strands. + +Which to pick, and why: [details](#led-driver-details). LED output driver controls Plus the [shared controls](#shared-driver-controls) above: -- `pins` β€” data GPIO list, e.g. `18,17,16`, or inclusive ranges like `20-23` (= `20,21,22,23`) mixed freely (`20-22,35,38-40`). One strand each β€” or, with Moon's pin expander, one *group of 8*. Empty idles until set; changing it re-inits live. +The card reads top-down as **invariant controls β†’ `peripheral` divider β†’ peripheral-specific controls**: + +- `pins` β€” data GPIO list, e.g. `18,17,16`, or inclusive ranges like `20-23` (= `20,21,22,23`) mixed freely (`20-22,35,38-40`). One strand each β€” or, with the `MoonI80` pin expander, one *group of 8*. Empty idles until set; changing it re-inits live. - `ledsPerPin` β€” lights per **strand**, following the broadcasting idiom (cf. NumPy / CSS shorthand): **empty** = even split of the window; **one number** = that many on *every* strand (`64` β†’ 64 each); **a list** `3,4,5` = one per strand by position (a short list even-splits the remainder). Shorter strands go dark early while the longest finishes. Through an expander an entry is one strand, not one pin, so two strands on one '595 can differ. -- **Expert-only** (πŸ”§, shown when `System.expertMode` is on): `loopbackTest` β€” a TXβ†’RX loopback self-test (jumper the first pin to `loopbackRxPin`), verdict in the status field, with `loopbackTxPin`/`loopbackRxPin` its wiring. Moon adds `shiftOverclock` and the manual `ring*` geometry knobs (below). +- `peripheral` (the **divider**) β€” the DMA peripheral driving the bus (`i80` / `Parlio` / `MoonI80`), filtered to what the chip supports. Everything **above** it is invariant (*which LEDs and how many*); everything **below** is what the chosen peripheral supports. Switching it re-surfaces that peripheral's own controls and re-inits live. Always shown β€” with a single option it reads as a labeled indicator of what's driving the LEDs. +- *peripheral-specific* (below the divider) β€” each shown only on the peripherals that support it, so the set changes when you switch `peripheral`: + - `doubleBuffer` β€” the async second frame buffer (encode overlaps the wire). Shown on `i80` and `Parlio` (they route through a real transaction queue); **hidden on `MoonI80`**, which runs single-buffer (its speed comes from the streaming ring, not from double-buffering a whole frame). + - `pinExpander` β€” the 74HCT595 fan-out (one pin β†’ 8 strands). Shown on the LCD_CAM family (`i80` and `MoonI80`), hidden on `Parlio` (its single-shot transfer can't carry the Γ—8 fan-out frame). + - `i80`: the WR/DC bus pins (`clockPin`/`dcPin`). `MoonI80`: `shiftOverclock` and the `ring*` geometry cluster. `Parlio`: no extra pins. +- **Expert-only** (πŸ”§, shown when `System.expertMode` is on): `loopbackTest` β€” a TXβ†’RX loopback self-test (jumper the first pin to `loopbackRxPin`), verdict in the status field, with `loopbackTxPin`/`loopbackRxPin` its wiring. + +Two ParallelLedDriver instances that select peripherals on the **same hardware block** (e.g. both `i80` and `MoonI80`, which share LCD_CAM) conflict β€” the second idles with a status. Different blocks (RMT + `Parlio` + `i80` on a P4) coexist. Origin: WS2812B on FastLED / hpwit / WLED prior art ([analysis](../../history/leddriver-analysis-top-down.md)) -Tests: [RMT](../../tests/unit-tests.md#rmtleddriver) Β· [MultiPin](../../tests/unit-tests.md#multipinleddriver) Β· [Moon](../../tests/unit-tests.md#moonleddriver) Β· [Parlio](../../tests/unit-tests.md#parlioleddriver) Β· [shared](../../tests/unit-tests.md#parallelleddriver) +Tests: [RMT](../../tests/unit-tests.md#rmtleddriver) Β· [shared + peripherals](../../tests/unit-tests.md#parallelleddriver) -Detail: [RMT](moxygen/RmtLedDriver.md) Β· [MultiPin](moxygen/MultiPinLedDriver.md) Β· [Moon](moxygen/MoonLedDriver.md) Β· [Parlio](moxygen/ParlioLedDriver.md) +Detail: [RMT](moxygen/RmtLedDriver.md) Β· [Parallel](moxygen/ParallelLedDriver.md) Β· peripherals: [i80](moxygen/MultiPinLedDriver.md) Β· [MoonI80](moxygen/MoonLedDriver.md) Β· [Parlio](moxygen/ParlioLedDriver.md) ## Network drivers @@ -114,32 +130,34 @@ Detail: [technical](moxygen/PreviewDriver.md) **Which driver?** +RMT is its own driver; the rest are `peripheral` choices on the one **Parallel LED** driver. + | Want | Use | Why | |---|---|---| -| A few strands, any ESP32 | **RMT** | The default. Simple, no bus width to think about, one channel per strand. | -| Many strands (up to 16) | **MultiPin** | The scale path where RMT runs out of channels. One DMA transfer drives every strand at once. | -| Up to 16 strands on a **P4** | **Parlio** | The P4's own parallel peripheral β€” it generates its pixel clock, so there is no clock pin to spend. | -| **More lights than fit one DMA buffer**, or **more strands than you have GPIOs** | **Moon** | The same LCD_CAM output as MultiPin, on our own DMA: it *streams* the frame, and it drives a 74HCT595 **pin expander** β€” 6 pins β†’ 48 strands. | +| A few strands, any ESP32 | **RMT** driver | The default. Simple, no bus width to think about, one channel per strand. | +| Many strands (up to 16) | Parallel LED, peripheral **`i80`** | The scale path where RMT runs out of channels. One DMA transfer drives every strand at once. | +| Up to 16 strands on a **P4** | Parallel LED, peripheral **`Parlio`** | The P4's own parallel peripheral β€” it generates its pixel clock, so there is no clock pin to spend. | +| **More lights than fit one DMA buffer**, or **more strands than you have GPIOs** | Parallel LED, peripheral **`MoonI80`** | The same LCD_CAM output as `i80`, on our own DMA: it *streams* the frame, and it drives a 74HCT595 **pin expander** β€” 6 pins β†’ 48 strands. | -**Moon and MultiPin drive the same pins the same way; only the DMA underneath differs.** Start with MultiPin β€” it is the proven path. Choose Moon when you hit one of its two limits. Both are registered module types, so you can swap them in the UI on one board with no reflash. +**`MoonI80` and `i80` drive the same pins the same way; only the DMA underneath differs.** Start with `i80` β€” it is the proven path. Choose `MoonI80` when you hit one of its two limits. Both are `peripheral` choices on the one Parallel LED driver, so you switch between them with the `peripheral` control on one board with no reflash. -**The four, compared.** All drive WS2812B-class strips with the same `pins` / `ledsPerPin` / `loopback*` controls and the same wire contract; they differ in parallelism, chip, and β€” for the two i80-bus entries (**MultiPin** and **Moon**) β€” in who programs the DMA. +**RMT vs the three parallel peripherals.** All drive WS2812B-class strips with the same `pins` / `ledsPerPin` / `loopback*` controls and the same wire contract; they differ in parallelism, chip, and β€” for the two i80-bus peripherals (**i80** and **MoonI80**) β€” in who programs the DMA. **Lane, pin, strand.** A **lane** is one bus data line; a **strand** is one chain of LEDs. The i80 **bus** is 8 or 16 lanes wide (a hardware fact β€” `lcd_ll_set_data_wire_width` takes nothing else), but you configure only the **pins** that drive something, at any count from 1: the driver rounds the bus up around them and parks the spare lanes on a pin the peripheral already drives, where nothing reads them. - **Direct:** one pin = one lane = one strand. 1–16 strands. - **Through an expander:** each data pin feeds one '595 and fans out to 8 strands, so **1–8 data pins β†’ up to 64 strands** (the driver's ceiling). The **latch** also costs a lane β€” the peripheral has only one clock output, so it has to ride a data line β€” but the strand ceiling binds first. hpwit's board populates 6 pins β†’ **48 strands**. -| Driver | Chip | Strands | Extra controls | Notes | -|---|---|---|---|---| -| **RMT** ([detail](moxygen/RmtLedDriver.md)) | any ESP32 (classic 8 ch, S3 4, P4 4 DMA) | one per RMT TX channel | `loopbackFrame` | The general single-/few-strand output; default for classic + S3 board entries. `loopbackFrame` bit-verifies a *whole frame*, catching frame-rate / RF corruption a 24-bit burst misses. | -| **MultiPin** ([detail](moxygen/MultiPinLedDriver.md)) | S3 / P4 (LCD_CAM) Β· classic (I2S) | **1–16** | `clockPin` `dcPin` | One driver over IDF's `esp_lcd` i80 bus. The **bus** is 8 or 16 bits wide (≀8 pins β†’ 8-bit, 9–16 β†’ 16-bit) β€” but the **pin count is free**: configure only the pins that drive something and the driver rounds the bus up around them, parking the spare lanes on a pin the peripheral already drives. `clockPin`/`dcPin` are i80 bus lines the LEDs ignore. **Capped by one contiguous DMA buffer**: the classic backend is internal-RAM only (I2S can't reach PSRAM) β†’ **2048 lights**; LCD_CAM draws from PSRAM β†’ **16384**. Over the cap it idles with a status rather than crashing. | -| **Moon** ([detail](moxygen/MoonLedDriver.md)) | S3 / P4 (LCD_CAM only) | **1–16**; Γ—8 per pin with an expander (**6 pins β†’ 48 strands**) | `clockPin` `pinExpander` `latchPin` `useRing` `ringAuto`; πŸ”§ `shiftOverclock` `ringRows` `ringBufs` `ringPadUs` | The same LCD_CAM output as MultiPin on **our own GDMA chain**, which buys two things `esp_lcd` cannot: a frame **streamed** through a small buffer pool instead of held whole (so length stops being a memory question), and a **74HCT595 pin expander** β€” one GPIO fans out to 8 strands. `ringAuto` (default on) derives the streaming geometry per config, so the manual `ring*` knobs and `shiftOverclock` (a faster '595 clock for short-wired rigs) are expert-only tuning β€” the full guide is on the technical page. No `dcPin` at all, and WR is routed only when a '595 needs it as its shift clock. Not on the classic ESP32 (its i80 is the I2S peripheral). The prime-only ring (frame fits the buffer pool) and the pin expander are wall-solid; the **lapping** ring (very long strands, where the ISR refills from a PSRAM source) has a known last-row sparkle on the largest configs, tracked in [the backlog](../../backlog/backlog-light.md). Why + what it costs: [ADR-0014](../../adr/0014-own-i80-dma-driver-below-esp-lcd.md). | -| **Parlio** ([detail](moxygen/ParlioLedDriver.md)) | ESP32-P4 | **1–16** | β€” | The P4's parallel path; Parlio generates its own pixel clock, so no clock/dc pins to spend. Bus width follows the pin count. On P4-NANO a known-good 8-set is `20,21,22,23,24,25,26,27`. | +| Output | `peripheral` | Chip | Strands | Extra controls | Notes | +|---|---|---|---|---|---| +| **RMT** ([detail](moxygen/RmtLedDriver.md)) | *(own driver)* | any ESP32 (classic 8 ch, S3 4, P4 4 DMA) | one per RMT TX channel | `loopbackFrame` | The general single-/few-strand output; default for classic + S3 board entries. `loopbackFrame` bit-verifies a *whole frame*, catching frame-rate / RF corruption a 24-bit burst misses. | +| Parallel LED | **`i80`** | S3 / P4 / S31 (LCD_CAM) Β· classic (I2S) | **1–16** | `clockPin` `dcPin` | Over IDF's `esp_lcd` i80 bus. The **bus** is 8 or 16 bits wide (≀8 pins β†’ 8-bit, 9–16 β†’ 16-bit) β€” but the **pin count is free**: configure only the pins that drive something and the driver rounds the bus up around them, parking the spare lanes on a pin the peripheral already drives. `clockPin`/`dcPin` are i80 bus lines the LEDs ignore. **Capped by one contiguous DMA buffer**: the classic backend is internal-RAM only (I2S can't reach PSRAM) β†’ **2048 lights**; LCD_CAM draws from PSRAM β†’ **16384**. Over the cap it idles with a status rather than crashing. | +| Parallel LED | **`MoonI80`** | S3 / P4 / S31 (LCD_CAM only) | **1–16**; Γ—8 per pin with an expander (**6 pins β†’ 48 strands**) | `clockPin` `pinExpander` `latchPin` `useRing` `ringAuto`; πŸ”§ `shiftOverclock` `ringRows` `ringBufs` `ringPadUs` | The same LCD_CAM output as `i80` on **our own GDMA chain**, which buys two things `esp_lcd` cannot: a frame **streamed** through a small buffer pool instead of held whole (so length stops being a memory question), and a **74HCT595 pin expander** β€” one GPIO fans out to 8 strands. `ringAuto` (default on) derives the streaming geometry per config, so the manual `ring*` knobs and `shiftOverclock` (a faster '595 clock for short-wired rigs) are expert-only tuning β€” the full guide is on the technical page. No `dcPin` at all, and WR is routed only when a '595 needs it as its shift clock. Not on the classic ESP32 (its i80 is the I2S peripheral). The prime-only ring (frame fits the buffer pool) and the pin expander are wall-solid; the **lapping** ring (very long strands, where the ISR refills from a PSRAM source) has a known last-row sparkle on the largest configs, tracked in [the backlog](../../backlog/backlog-light.md). Why + what it costs: [ADR-0014](../../adr/0014-own-i80-dma-driver-below-esp-lcd.md). | +| Parallel LED | **`Parlio`** | ESP32-P4 | **1–16** | β€” | The P4's parallel path; Parlio generates its own pixel clock, so no clock/dc pins to spend. Bus width follows the pin count. On P4-NANO a known-good 8-set is `20,21,22,23,24,25,26,27`. | -The detail pages carry each driver's wire contract, buffer slicing, memory sizing, and the loopback self-test. +The [Parallel LED technical page](moxygen/ParallelLedDriver.md) carries the wire contract, buffer slicing, memory sizing, and the loopback self-test; each peripheral's own page ([i80](moxygen/MultiPinLedDriver.md) Β· [MoonI80](moxygen/MoonLedDriver.md) Β· [Parlio](moxygen/ParlioLedDriver.md)) covers its DMA specifics. -**What the parallel drivers share.** MultiPin, Moon and Parlio are thin peripheral shells over two common pieces β€” worth reading if you care how a frame is actually built: +**What the peripherals share.** The three parallel peripherals are thin shells the one Parallel LED driver selects between; two common pieces do the real work β€” worth reading if you care how a frame is actually built: -- **[Parallel LED driver base](moxygen/ParallelLedDriver.md)** β€” the shared body: strand slicing, the encode loop, the async double-buffer, the latch pad, the loopback self-test. A derived driver adds only its peripheral's DMA calls. +- **[Parallel LED driver](moxygen/ParallelLedDriver.md)** β€” the shared body: strand slicing, the encode loop, the async double-buffer, the latch pad, the loopback self-test. A peripheral backend adds only its own DMA calls. - **[Slot encoder](moxygen/ParallelSlots.md)** β€” the wire format itself. Each WS2812 bit becomes three bus slots (pulse start / data / tail), and the data slot is an **8Γ—8 bit transpose**: lanes in, bit-planes out, so one bus word carries the same bit of every strand. It is the render loop's measured hot spot. diff --git a/docs/performance.md b/docs/performance.md index 7b5b0ebc..8fd26eee 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -236,6 +236,10 @@ The tick cost is native-code speed β€” a `setRGB` is a bounds-guard + three byte ## Multi-pin LED driving (all three peripherals, 128Γ—128 grid) +The rows below name the peripherals by their pre-consolidation driver-class names (`MultiPinLedDriver` = the `i80` peripheral, `MoonLedDriver` = `MoonI80`, `ParlioLedDriver` = `Parlio`). They are dated bench records kept as measurements; the three are now one `ParallelLedDriver` whose `peripheral` control selects the backend (see [ADR-0016](adr/0016-one-parallel-led-driver-runtime-peripheral-strategy.md)). The timings are unchanged by the consolidation (one vtable dispatch per frame, never per light). + +**Async double-buffer is peripheral-specific.** The `doubleBuffer` win recorded in the Parlio row below (the ~7.5 ms wire hidden behind background DMA) applies to `i80` and `Parlio` β€” they route through a real transaction queue (esp_lcd / the Parlio driver) that absorbs the second in-flight transfer. `MoonI80` runs **single-buffer only** (`supportsDoubleBuffer()` false, and the control is hidden on it): its own-GDMA whole-frame two-buffer handshake races and wedges the bus, and its speed comes from the streaming ring, not from double-buffering a whole frame. On MoonI80 the whole-frame path is therefore encode β†’ transmit β†’ wait, serial per frame (the ring is the scale path). + Each parallel LED driver run on real hardware at a 128Γ—128 = 16384-light grid, 8 lanes (2026-07-12). The **GPIOs used are recorded** because they double as the seed for each board's usable-pin map (the per-model `deviceModels.json` pin defaults are built from proven-working sets, not datasheet guesses). Every listed pin drove WS2812 output on that board without conflict. | Peripheral | Board | Pins used (8 lanes) | Result | Ceiling / bound | diff --git a/docs/reference/esp32-s31-coreboard.md b/docs/reference/esp32-s31-coreboard.md index 1160a5fd..b31bc52a 100644 --- a/docs/reference/esp32-s31-coreboard.md +++ b/docs/reference/esp32-s31-coreboard.md @@ -122,8 +122,8 @@ The two pins in **one column are physically stacked**, so a 2-pin jumper cap bri **Recommended assignment** (what the S31 catalog entry uses): -- **LED strip data:** the onboard WS2812 is on **GPIO 60** (the catalog default). For an *external* strand, use **GPIO 42** as the single-lane pick; a parallel rig (RMT/Parlio) takes the free block (**36–49**, skipping 41 which isn't broken out) for several lanes. **GPIO 4** (col 16 top) also works as an LED data pin and sits one column from the `G` / `3V3` / `5V` power rail (cols 17–20), so a single strip's data + ground + 5 V wires land close together β€” handy for a tidy 3-wire pigtail. It's a plain I/O with no strap or peripheral tie on this board (the SD lines beside it, D0–D3 / CLK / CMD, are broken out by function name, not GPIO number, so GPIO 4 is *not* one of them; it just neighbours that cluster on the header). The only reason it reads as "distinct" from the rest of the free run is its header position β€” it's over by the SD/power group rather than in the low-block on cols 8–12. -- **Loopback self-test:** **Tx = GPIO 48, Rx = GPIO 47** β€” the two pins of **column 7** (48 top, 47 bottom), so a single jumper cap shorts them. A driver transmits a known WS2812 frame out Tx and reads it back on Rx to verify output on real silicon (same pattern as the P4-NANO bench's 32↔33). They sit at the top of the free run, clear of the operational LED pins so the strip wiring and the jumper don't interfere. **Bench-confirmed PASS on the S31 for both [RmtLedDriver](../moonmodules/light/drivers.md#rmtled) and [ParlioLedDriver](../moonmodules/light/drivers.md#parlioled)** β€” the two WS2812 output drivers the S31 supports. [LcdLedDriver](../moonmodules/light/drivers.md#led-drivers) is **not** one of them: it's the ESP32-S3-specific LCD_CAM i80 driver, and the RISC-V S31 has no LCD_CAM peripheral (it reports "no valid pins"). Testing several drivers in a row, they all default loopback to GPIO 48, so only one can hold the pin at a time β€” toggle each driver's `loopbackTest` off before testing the next. +- **LED strip data:** the onboard WS2812 is on **GPIO 60** (the catalog default). For an *external* strand, use **GPIO 42** as the single-lane pick; a parallel rig (RMT/Parlio) takes the free block (**36–49**, skipping 41 which isn't broken out) for several lanes. **GPIO 4** (col 16 top) also works as an LED data pin and sits one column from the `G` / `3V3` / `5V` power rail (cols 17–20), so a single strip's data + ground + 5 V wires land close together β€” handy for a tidy 3-wire pigtail. It's a plain I/O with no strap or peripheral tie on this board (the SD lines beside it, D0–D3 / CLK / CMD, are broken out by function name, not GPIO number, so GPIO 4 is *not* one of them; it just neighbors that cluster on the header). The only reason it reads as "distinct" from the rest of the free run is its header position β€” it's over by the SD/power group rather than in the low-block on cols 8–12. +- **Loopback self-test:** **Tx = GPIO 48, Rx = GPIO 47** β€” the two pins of **column 7** (48 top, 47 bottom), so a single jumper cap shorts them. A driver transmits a known WS2812 frame out Tx and reads it back on Rx to verify output on real silicon (same pattern as the P4-NANO bench's 32↔33). They sit at the top of the free run, clear of the operational LED pins so the strip wiring and the jumper don't interfere. **Bench-confirmed on the S31 for [RMT](../moonmodules/light/drivers.md#rmtled) and the [Parallel LED driver](../moonmodules/light/drivers.md#parallelled) across all three of its peripherals.** The S31 SOC has a real LCD_CAM (`SOC_LCDCAM_I80_LCD_SUPPORTED`), so its `i80` backend is LCD_CAM-driven (not the classic ESP32's I2S-in-i80-mode, which the platform layer excludes on any chip with real LCD_CAM) and can draw its frame from PSRAM. The `peripheral` selector therefore offers all three: **`i80`, `MoonI80`, and `Parlio`** β€” the RGMII Ethernet does not take the LCD_CAM block. Bench-verified: an 8x8 panel driving on `i80` (data GPIO 60), and Parlio on the free-block pins. Testing several drivers in a row, they all default loopback to GPIO 48, so only one can hold the pin at a time β€” toggle each driver's `loopbackTest` off before testing the next. ## SoC capabilities (from `components/soc/esp32s31/include/soc/soc_caps.h`) diff --git a/docs/reference/mhc-wled-esp32-p4-shield.md b/docs/reference/mhc-wled-esp32-p4-shield.md index c3d46e89..d963c345 100644 --- a/docs/reference/mhc-wled-esp32-p4-shield.md +++ b/docs/reference/mhc-wled-esp32-p4-shield.md @@ -1,25 +1,32 @@ # MHC-WLED ESP32-P4 shield β€” hardware reference -Terminal pinout and onboard features for the **MHC-WLED ESP32-P4 shield** (myhome-control), the P4-NANO carrier used on the bench (catalog `deviceModel: "MHC-WLED ESP32-P4 shield"`, `esp32p4-eth` firmware). Read from the board silkscreen so projectMM work reads this instead of the marketing render. The shield sits on a **Waveshare ESP32-P4-NANO**; GPIO numbers are the P4's. +Terminal pinout and onboard features for the **MHC-WLED ESP32-P4 shield** (myhome-control), the P4-NANO carrier used on the bench (catalog `deviceModel: "MHC-WLED ESP32-P4 shield"`, `esp32p4-eth` firmware). Read from the board silkscreen + the builder's schematics so projectMM work reads this instead of the marketing render. The shield sits on a **Waveshare ESP32-P4-NANO**; GPIO numbers are the P4's. + +> **Board revision:** the terminal map and RS-485 wiring below are transcribed from a **V1** board (the builder's labelled V1 photos + schematics). The overview render is a **V2** render. Whether V2 keeps the identical GPIO↔terminal wiring is **not confirmed here** β€” treat the map as V1-specific and verify against your own board's silkscreen if you have a different revision. **Sources** - Overview render (board V2): [`docs/assets/deviceModels/mhc-wled-esp32-p4-shield.jpg`](../assets/deviceModels/mhc-wled-esp32-p4-shield.jpg) -- Silkscreen (photographed, 2026-07-09): the terminal labels below are transcribed from the physical board, which supersedes the render where they differ (the render's RS-485 "GPIOs 3,4,6,53" is wrong β€” the board reads 4, 22, 24, 3). -- Builder: myhome-control (Wladi). +- Silkscreen (photographed) + the builder's V1 schematics and terminal maps (myhome-control / Wladi, 2026-07-16); the transcriptions below come from those. The schematics supersede the marketing render where they differ. ## Pinout -![MHC-WLED ESP32-P4 shield terminal pinout](../assets/reference/mhc-wled-esp32-p4-shield-pinout.svg) +### GPIO ↔ screw-terminal map (V1 board) + +The output/RS-485 terminals, left to right, with the P4 GPIO each carries: + +![MHC-WLED ESP32-P4 shield GPIO terminal map](../assets/reference/mhc-wled-esp32-p4-shield-gpio-terminal-map.png) + +`O21 O20 O25 O5 O7 O23 O8 O27 O3 O22 O24 O4` β€” the level-shifted single-ended LED outputs, then the four RS-485 differential pairs. **Nothing on this shield is a bare GPIO.** Every terminal routes through protection or level-shifting β€” the reason a direct drive-and-read WS2812 loopback jumper fails on it (see below). Four terminal groups: ### 12x outputs β€” level-shifted, single-ended (LED data) -The LED-data outputs. Each terminal is `O` on the silkscreen; a level shifter drives the 5 V strand from the P4's 3.3 V. The catalog wires the ParlioLedDriver to the first eight (`21,20,25,5,22,23,24,27`). +The LED-data outputs. Each terminal is `O` on the silkscreen; a level shifter drives the 5 V strand from the P4's 3.3 V. The catalog wires the Parallel LED driver (peripheral `Parlio`) to the first eight (`21,20,25,5,22,23,24,27`). | Terminal | GPIO | Note | |---|---|---| -| O21 O20 O25 O5 O23 O27 O22 O24 | 21 20 25 5 23 27 22 24 | LED lanes (ParlioLedDriver default) | +| O21 O20 O25 O5 O22 O23 O24 O27 | 21 20 25 5 22 23 24 27 | LED lanes (Parallel LED, peripheral `Parlio`, default) | | O7 / O8 | 7 / 8 | also the IΒ²C bus (SDA 7 / SCL 8, catalog I2cScan) | | O3 | 3 | also on RS-485 (`A-3-B`) β€” see note below | | O4 | 4 | also on RS-485 (`A-4-B`) β€” see note below | @@ -27,20 +34,37 @@ The LED-data outputs. Each terminal is `O` on the silkscreen; a level shif > **GPIO 3, 4, 22, 24 each appear TWICE** β€” once here (level-shifted single-ended output, `O`) and once in the RS-485 block (`A--B`). It's the *same* P4 GPIO fanned out to two output forms: driving the pin lights up **both** its `O` terminal and its `A--B` transceiver at once. Wire to whichever form you need. GPIO 21/20/25/5/23/27 have **only** the level-shifted path (no RS-485), which is why the LED-driver default uses those + 22/24 for strips and leaves 3/4 free. -### 4x RS-485 β€” differential A/B pairs +### 4x RS-485 β€” differential A/B pairs (range extender + DMX) -Each channel is `A--B` on the silkscreen: an **RS-485 transceiver** (not a bare GPIO) driven by that GPIO. **Each channel occupies TWO screw terminals β€” an `A` and a `B`** (the differential pair), so the 4 channels are 8 terminals total. Channels on **GPIO 4, 22, 24, 3**. The render mentions "switch GPIO 3 as input/output," but there's **no physical switch on the board** β€” GPIO 3 is just a GPIO, and projectMM sets its direction in firmware (an output when driving, an input when `loopbackRxPin` reads it). +Each channel is `A--B` on the silkscreen: an **RS-485 transceiver** (an SP3485EN-L/TR, not a bare GPIO) driven by that GPIO, with 120 Ξ© termination, resettable fuses (nSMD010), and TVS protection (CDSOT23-SM712-ES) on the line. **Each channel occupies TWO screw terminals β€” an `A` and a `B`** (the differential pair), so the 4 channels are 8 terminals total. Channels on **GPIO 4, 22, 24, 3**. -| Channel | GPIO | Terminals | -|---|---|---| -| A-4-B | 4 | A4, B4 | -| A-22-B | 22 | A22, B22 | -| A-24-B | 24 | A24, B24 | -| A-3-B | 3 | A3, B3 | +RS-485 is here for two purposes: + +- **Range extender** β€” RS-485's differential pair carries LED data far past what a single-ended 5 V line manages. At the LED end you need an **RS-485 receiver with a 5 V data output** to convert the differential signal back to the WS2812 single-ended waveform. +- **DMX-512 output** β€” DMX's physical layer *is* RS-485, so these channels double as DMX outputs. Wire an XLR connector to `GND`, `A`, `B`; in DMX nomenclature **A is Dataβˆ’ (Signalβˆ’), B is Data+ (Signal+)**. + +**Three channels are transmit-only; one (GPIO 3) is switchable.** On the transmit-only channels (GPIO 4, 22, 24) the transceiver's `RE#`/`DE` direction pins are hard-wired to transmit (`DI` in, `RO` disconnected): + +![RS-485 transmit-only channel schematic (GPIO 4)](../assets/reference/mhc-wled-esp32-p4-shield-rs485-transmit-schematic.png) + +The **GPIO 3 channel adds a mechanical slide switch** (SW5, MSK12C02) that ties the transceiver's `RE#`/`DE` to 3V3 or GND β€” i.e. it selects **transmit mode** (`DI`, GPIO 3 drives the line) or **receive mode** (`RO`, GPIO 3 reads the line): + +![RS-485 GPIO 3 switchable channel schematic](../assets/reference/mhc-wled-esp32-p4-shield-rs485-gpio3-switchable-schematic.png) + +| Channel | GPIO | Terminals | Direction | +|---|---|---|---| +| A-4-B | 4 | A4, B4 | transmit only | +| A-22-B | 22 | A22, B22 | transmit only | +| A-24-B | 24 | A24, B24 | transmit only | +| A-3-B | 3 | A3, B3 | transmit **or** receive (board switch) | ### 4x in/out header -The `O46 O47 O2 O48` header plus power (`GND`, `In5V`, `Out3V3`). Inputs are **diode-protected with a ~16 kHz low-pass filter** β€” designed for robust button-style inputs, not high-speed signals. GPIO 2 and 46 are P4 **boot straps**. This header is *not* usable for a WS2812 loopback (the filter and protection destroy the ~800 kHz waveform β€” the `hi=0 lo=0` continuity result that pinned this). +The `O46 O47 O2 O48` header plus power (`GND`, `In5V`, `Out3V3`): + +![MHC-WLED ESP32-P4 shield in/out header](../assets/reference/mhc-wled-esp32-p4-shield-inout-header.png) + +Inputs are **diode-protected with a ~16 kHz low-pass filter** β€” designed for robust button-style inputs, not high-speed signals. GPIO 2 and 46 are P4 **boot straps**. This header is *not* usable for a WS2812 loopback (the filter and protection destroy the ~800 kHz waveform). ### Line-In audio (PCM1808 β†’ IΒ²S) @@ -52,12 +76,16 @@ The P4-NANO's RMII PHY: **MDC 31 Β· MDIO 52 Β· RST 51 Β· CLK 50 (external-in) Β· ## Loopback self-test on this shield -The loopback self-test drives a WS2812 frame out one pin and reads it back on a jumpered pin β€” so it needs a **bare GPIO pair**. This shield exposes none: every terminal is buffered (level shifter, RS-485 transceiver, or diode + low-pass). **The self-test therefore does not apply to this shield β€” that's by design, not a firmware gap.** +The loopback self-test drives a WS2812 frame out one pin and reads it back on a jumpered pin β€” so it needs a signal path from a Tx pin to an Rx pin. The bare-GPIO terminals can't provide it (every one is buffered), but the **GPIO 3 switchable RS-485 channel can**, because its board switch turns GPIO 3 into a data *input*: + +- **Set the GPIO 3 board switch to the receive (input) position**, then jumper the RS-485 differential pairs `A4β†’A3` and `B4β†’B3` (the wiring the builder shows): + + ![RS-485 loopback wiring: A4β†’A3, B4β†’B3, GPIO 3 switch in input position](../assets/reference/mhc-wled-esp32-p4-shield-rs485-loopback-wiring.png) -- The frame-size fix the test exercises is **already proven** on the bare P4-NANO (direct GPIO 32↔33, PASS at every grid size), so the shield doesn't need to re-prove it. -- To verify LED output *on the shield*, the honest test is to wire a real **WS2812 strip to an `O` output** and watch it light β€” that exercises the true path (GPIO β†’ level shifter β†’ strip), which is what the shield is built for. -- The RS-485 channels (the builder's suggested loopback path, Tx=GPIO 4 `A-4-B` β†’ Rx=GPIO 3 `A-3-B`, wired A4β†’A3 / B4β†’B3) are **unlikely to work as a loopback**: projectMM drives pins as plain GPIO with **no RS-485 direction control** (no DE/RE driver-enable / receiver-enable toggling), so the transceivers won't reliably switch Tx↔Rx, and the differential path is slew-limited for an ~800 kHz WS2812 waveform anyway. Half-duplex RS-485 (DE/RE) is a real feature, not something the loopback path gets for free β€” see the [RS-485 / DMX-512 wired-output future extension](../backlog/backlog-light.md#rs-485-dmx-512-wired-output-future-the-physical-dmx-driver). +- The signal path is: **GPIO 4 emits the WS2812 frame β†’ the first RS-485 transceiver drives it as a differential signal on `A4`/`B4` β†’ the second transceiver reads it back β†’ GPIO 3 receives it as a 3.3 V data input.** So the loopback runs **Tx = GPIO 4, Rx = GPIO 3** with the switch in the input position. +- The bare P4-NANO already proves the frame-size fix directly (GPIO 32↔33, PASS at every grid size), so the shield doesn't need to re-prove it β€” but this RS-485 path is the builder's intended on-shield loopback, distinct from the bare-GPIO jumper the self-test defaults to. +- To verify LED output *on the shield*, the other honest test is to wire a real **WS2812 strip to an `O` output** and watch it light β€” that exercises the true path (GPIO β†’ level shifter β†’ strip), which is what the shield is built for. ## Cross-reference -Chip-level GPIO constraints (straps, flash/PSRAM) for the P4 are in [gpio-usage.md Β§ ESP32-P4](gpio-usage.md#esp32-p4); this page is the *board* wiring. The catalog entry is [`web-installer/deviceModels.json`](../../web-installer/deviceModels.json) (`MHC-WLED ESP32-P4 shield`). +Chip-level GPIO constraints (straps, flash/PSRAM) for the P4 are in [gpio-usage.md Β§ ESP32-P4](gpio-usage.md#esp32-p4); this page is the *board* wiring. The catalog entry is [`web-installer/deviceModels.json`](../../web-installer/deviceModels.json) (`MHC-WLED ESP32-P4 shield`). RS-485 / DMX-512 as a first-class projectMM output is tracked in the [RS-485 / DMX-512 wired-output backlog item](../backlog/backlog-light.md#rs-485-dmx-512-wired-output-future-the-physical-dmx-driver). diff --git a/moondeck/_moondeck_config.py b/moondeck/_moondeck_config.py new file mode 100644 index 00000000..2a20ee61 --- /dev/null +++ b/moondeck/_moondeck_config.py @@ -0,0 +1,96 @@ +"""Shared moondeck.json helpers used across the check/ and run/ scripts. + +Kept dependency-free (stdlib only) so a PEP-723 script can import it after adding +moondeck/ to sys.path, without threading extra `--with` deps. Mirrors the shared +`_net_probe.py` pattern in scenario/, one level up so both check/ and run/ reach it. +""" + +import json +import urllib.request +from contextlib import contextmanager +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +_STATE = _ROOT / "moondeck" / "moondeck.json" + +# logLevel option indices, matching SystemModule's addSelect order +# (None, Error, Warn, Info, Debug, Verbose). +LOG_NONE, LOG_ERROR, LOG_WARN, LOG_INFO, LOG_DEBUG, LOG_VERBOSE = range(6) + + +def active_device_ips(): + """ESP32 device IPs in the active network (from moondeck.json). Skips the desktop + entry (its ip carries a :port) and anything without an ip. [] if unresolvable.""" + if not _STATE.exists(): + return [] + try: + state = json.loads(_STATE.read_text(encoding="utf-8")) + active = next((n for n in (state.get("networks") or []) + if n.get("name") == state.get("active_network")), None) + return [d["ip"] for d in (active or {}).get("devices", []) + if d.get("ip") and ":" not in d["ip"]] + except Exception: + return [] + + +def _get_log_level(ip): + """Read one device's current System.logLevel index from /api/state, or None if unreachable + or unparseable. Used to snapshot a device before a temporary change so it can be restored.""" + try: + with urllib.request.urlopen(f"http://{ip}/api/state", timeout=3) as r: + state = json.loads(r.read().decode("utf-8", errors="replace")) + except Exception: + return None + + def walk(node): + if isinstance(node, dict): + if node.get("type") == "SystemModule": + for c in node.get("controls", []): + if c.get("name") == "logLevel": + v = c.get("value") + return v if isinstance(v, int) else None + for v in node.values(): + found = walk(v) + if found is not None: + return found + elif isinstance(node, list): + for v in node: + found = walk(v) + if found is not None: + return found + return None + + return walk(state) + + +def set_log_level(ips, index): + """POST System.logLevel= to each device IP. The value is a numeric JSON index + (a quoted value parses to 0). Best-effort per device: a stale/absent entry is skipped, + never raised, so it can't fail the caller's real work (a KPI capture, a monitor session).""" + body = json.dumps({"module": "System", "control": "logLevel", "value": index}).encode("utf-8") + for ip in ips: + try: + # Request() is inside the try too: a malformed IP makes its construction raise, and that + # must skip only this device β€” not abort the loop and strand the rest (which, under + # raised_log_level, would also skip the restore of every device after it). + req = urllib.request.Request(f"http://{ip}/api/control", data=body, + headers={"Content-Type": "application/json"}, method="POST") + urllib.request.urlopen(req, timeout=3).read() + except Exception: + pass + + +@contextmanager +def raised_log_level(ips, index=LOG_INFO): + """Temporarily raise each device to `index` (default Info, so the serial tick line prints) and + restore each device's ORIGINAL level on exit β€” not a hardcoded Warn, so a device the user had set + to Debug/Error keeps its choice. A device whose level can't be read is still raised, and restored + to Warn (the resting default) as the honest fallback. Best-effort throughout; never raises.""" + saved = {ip: _get_log_level(ip) for ip in ips} + set_log_level(ips, index) + try: + yield + finally: + for ip in ips: + prev = saved.get(ip) + set_log_level([ip], prev if prev is not None else LOG_WARN) diff --git a/moondeck/check/collect_kpi.py b/moondeck/check/collect_kpi.py index bff011f4..2dfd2898 100644 --- a/moondeck/check/collect_kpi.py +++ b/moondeck/check/collect_kpi.py @@ -20,6 +20,10 @@ ROOT = Path(__file__).resolve().parent.parent.parent ESP32_DIR = ROOT / "esp32" +# Shared moondeck.json + logLevel-toggle helpers (one level up, reachable from check/ and run/). +sys.path.insert(0, str(ROOT / "moondeck")) +from _moondeck_config import active_device_ips, raised_log_level, LOG_INFO # noqa: E402 + # Per-host desktop build dir (matches build_desktop.py / package_desktop.py). # We pick the directory belonging to the OS this script runs on so KPI # numbers reflect the binary the developer actually has on disk. @@ -287,22 +291,27 @@ def _live_capture(log, seconds=15): import serial except ImportError: return False + # Raise the device(s) to Info so the KPI tick line prints during the capture (they rest at Warn, + # which silences it), and restore each device's ORIGINAL level on exit β€” covering the serial-open + # failure and the normal-cleanup paths alike. A freshly booted device already logs at Info for its + # first 60 s, so this is a no-op there but harmless. print(f" ESP32 KPI: capturing {seconds}s from {port}...") - try: - ser = serial.Serial(port, 115200, timeout=1) - except Exception as e: - print(f" ESP32 KPI: cannot open {port}: {e}") - return False - end = time.time() + seconds - try: - with open(log, "w") as f: - while time.time() < end: - line = ser.readline().decode("utf-8", errors="replace").rstrip("\r\n") - if line: - f.write(line + "\n") - f.flush() - finally: - ser.close() + with raised_log_level(active_device_ips(), LOG_INFO): + try: + ser = serial.Serial(port, 115200, timeout=1) + except Exception as e: + print(f" ESP32 KPI: cannot open {port}: {e}") + return False + end = time.time() + seconds + try: + with open(log, "w") as f: + while time.time() < end: + line = ser.readline().decode("utf-8", errors="replace").rstrip("\r\n") + if line: + f.write(line + "\n") + f.flush() + finally: + ser.close() return True def collect_code(): diff --git a/moondeck/run/monitor_esp32.py b/moondeck/run/monitor_esp32.py index 4b4de46a..6b00b91b 100644 --- a/moondeck/run/monitor_esp32.py +++ b/moondeck/run/monitor_esp32.py @@ -13,37 +13,46 @@ ROOT = Path(__file__).resolve().parent.parent.parent LOG_FILE = ROOT / "esp32" / "monitor.log" +# Shared moondeck.json + logLevel-toggle helpers (one level up, reachable from check/ and run/). +sys.path.insert(0, str(ROOT / "moondeck")) +from _moondeck_config import active_device_ips, raised_log_level, LOG_INFO # noqa: E402 + def main(): parser = argparse.ArgumentParser() parser.add_argument("--port", required=True, help="Serial port") parser.add_argument("--baud", type=int, default=115200, help="Baud rate") args = parser.parse_args() + # Raise the device(s) to Info so the tick line shows while monitoring; restore each device's ORIGINAL + # level on exit (monitor failure and normal Ctrl+C alike), so a device the user set to Debug/Error + # keeps its choice. Best-effort β€” an un-networked device is skipped (and already logs at Info for its + # first 60 s anyway). print(f"Monitoring {args.port} at {args.baud} baud...") print(f"Log saved to {LOG_FILE}") print("Press Ctrl+C (or Stop in MoonDeck) to stop.\n") sys.stdout.flush() - try: - ser = serial.Serial(args.port, args.baud, timeout=1) - except serial.SerialException as e: - print(f"Cannot open {args.port}: {e}") - sys.exit(1) - - with open(LOG_FILE, "w") as log: + with raised_log_level(active_device_ips(), LOG_INFO): try: - while True: - line = ser.readline().decode("utf-8", errors="replace").rstrip("\r\n") - if line: - print(line) - sys.stdout.flush() - log.write(line + "\n") - log.flush() - except KeyboardInterrupt: - pass - finally: - ser.close() - print(f"\nStopped. Full log: {LOG_FILE}") + ser = serial.Serial(args.port, args.baud, timeout=1) + except serial.SerialException as e: + print(f"Cannot open {args.port}: {e}") + sys.exit(1) + + with open(LOG_FILE, "w") as log: + try: + while True: + line = ser.readline().decode("utf-8", errors="replace").rstrip("\r\n") + if line: + print(line) + sys.stdout.flush() + log.write(line + "\n") + log.flush() + except KeyboardInterrupt: + pass + finally: + ser.close() + print(f"\nStopped. Full log: {LOG_FILE}") if __name__ == "__main__": main() diff --git a/moondeck/run/preview_installer.py b/moondeck/run/preview_installer.py index 237abec0..74ef09e5 100644 --- a/moondeck/run/preview_installer.py +++ b/moondeck/run/preview_installer.py @@ -76,13 +76,18 @@ def _stage_runtime_files(src_dir: Path, dst_dir: Path): - """Copy every browser-loadable file (.html/.js/.css/.json/.png/.ico) from - src_dir to dst_dir β€” mirrors release.yml's `cp -r docs//. pages//`. - README.md / other .md are docs, skipped.""" - dst_dir.mkdir(parents=True, exist_ok=True) - for src in src_dir.iterdir(): - if src.is_file() and src.suffix.lower() in (".html", ".js", ".css", ".json", ".png", ".ico"): - shutil.copy(src, dst_dir / src.name) + """Copy every browser-loadable file (.html/.js/.css/.json/.png/.ico/.svg) from + src_dir to dst_dir β€” mirrors release.yml's `cp -r web-installer/. pages/install/`. + README.md / other .md are docs, skipped. The deploy's `cp -r` is recursive, so a + subdirectory of static assets (web-installer/assets/, the app-store badges) is + staged too β€” walk the tree rather than just the top level, or those 404 in preview + while working in production.""" + exts = (".html", ".js", ".css", ".json", ".png", ".ico", ".svg") + for src in src_dir.rglob("*"): + if src.is_file() and src.suffix.lower() in exts: + out = dst_dir / src.relative_to(src_dir) + out.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(src, out) def _stage_referenced_board_images(dst_dir: Path): diff --git a/moondeck/scenario/run_live_scenario.py b/moondeck/scenario/run_live_scenario.py index bd40552e..ad0e5a1e 100644 --- a/moondeck/scenario/run_live_scenario.py +++ b/moondeck/scenario/run_live_scenario.py @@ -208,6 +208,76 @@ def find(modules): return find(state.get("modules", [])) or [] +# Containers whose USER-ADDED children a scenario may clear/rebuild β€” the tree the +# snapshot/restore protects. A scenario that clear_children's one of these destroys +# the board's real config; restoring the snapshot afterward leaves the board as found. +_SNAPSHOT_CONTAINERS = {"Layouts", "Layers", "Drivers", "Services", "Layer"} + + +def _snapshot_tree(state: dict) -> list: + """Capture the user-added modules a scenario might clear or replace, in tree order + (parents before children), so they can be re-created after the scenario runs. + + Each entry is {type, id, parent_id, controls} β€” everything /api/state exposes to + reconstruct a module via POST /api/modules + /api/control. Boot-wired singletons + (the containers themselves, Preview, LightPresets) are NOT captured: the device + re-creates them itself and re-adding is a no-op or an error. We snapshot the + CHILDREN of the snapshot containers (and their descendants) β€” the modules a + scenario's clear_children / replace actually removes.""" + snap = [] + + def controls_of(m: dict) -> dict: + return {c["name"]: c.get("value") for c in m.get("controls", []) + if c.get("name") and not c.get("readonly")} + + def walk(modules: list, parent_name, *, inside_container: bool) -> None: + for m in modules: + name = m.get("name") + typ = m.get("type") + # Capture a module that sits INSIDE a snapshot container and is user-editable + # (a driver/effect/modifier/layout/service the scenario could clear). Skip the + # boot-wired ones the device owns (userEditable false is not in /api/state, so + # gate on the known singletons by name instead). + if inside_container and name and typ and name not in ("Preview", "LightPresets"): + snap.append({"type": typ, "id": name, + "parent_id": parent_name, "controls": controls_of(m)}) + walk(m.get("children", []), name, + inside_container=inside_container or name in _SNAPSHOT_CONTAINERS) + + walk(state.get("modules", []), None, inside_container=False) + return snap + + +def _restore_tree(client, snapshot: list, current_state: dict) -> None: + """Re-create any snapshotted module that a scenario removed, restoring the board to + the tree it had before the run. Adds parents before children (snapshot order) and + re-applies control values. A module still present is left untouched. A restore that + fails is reported (which module/control + the error), not silently swallowed β€” a + board left partially restored is a signal worth seeing, but one failure must not stop + the rest, so we log and continue.""" + present = _collect_module_names(current_state) + restored = 0 + for entry in snapshot: + if entry["id"] in present: + continue + try: + client.post("/api/modules", {"type": entry["type"], "id": entry["id"], + "parent_id": entry["parent_id"]}) + except Exception as e: + print(f" WARN β€” restore: could not re-create {entry['id']} " + f"({entry['type']} under {entry['parent_id']}): {e}") + continue + for cname, val in entry["controls"].items(): + try: + client.post("/api/control", {"module": entry["id"], + "control": cname, "value": val}) + except Exception as e: + print(f" WARN β€” restore: could not set {entry['id']}.{cname}={val!r}: {e}") + restored += 1 + if restored: + print(f" restored {restored} module(s) the scenario had cleared") + + def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, update_contract: bool = False, update_reason: str | None = None) -> dict: @@ -256,6 +326,7 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, # its set_control/replace ids won't exist on the device yet; they're created # mid-run. A still-unreachable id is a real typo / wrong-wiring bug. target = "unknown" + live_state = None # bound before the try so the snapshot below can't NameError if /api/state fails try: live_state = client.get("/api/state") target = _detect_target(live_state) @@ -290,6 +361,17 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, print(f" Target: {target}") results["target"] = target + # Snapshot the board's user-added tree so we can restore it after the scenario: + # a scenario that clear_children's a container (to get a known canvas) destroys the + # board's real config, and the created_modules cleanup only removes what the scenario + # ADDED, not what it CLEARED. Restoring the snapshot leaves the bench board as found. + tree_snapshot = [] + if live_state is not None: # skip restore if the pre-flight /api/state fetch failed (nothing to snapshot) + try: + tree_snapshot = _snapshot_tree(live_state) + except Exception as e: + print(f" WARN β€” couldn't snapshot tree for restore: {e}") + # Reset block: scenarios that mutate shared controls (Mirror toggles, grid # size, Preview detail, …) declare a `reset` array of set_control steps that # restores those controls to production defaults BEFORE the scenario runs. @@ -392,6 +474,15 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, # a grid a prior run's cleanup removed) is a no-op, not a fail. step_result["status"] = "ok" print(f" SET {step.get('id','?')}.{step.get('key','?')} β€” skipped (optional, not present)") + elif step.get("optional") and ce.code == 400: + # An `optional` set_control the device REJECTS with 400 (e.g. + # "value out of range") is a not-available-here skip, not a + # failure β€” e.g. selecting a `peripheral` value this chip doesn't + # offer (Parlio on an S3, MoonI80 on a classic): the Select's max + # is the board-filtered option count, so the value is out of range + # and returns 400. A REQUIRED set_control that 400s still fails. + step_result["status"] = "skipped" + print(f" SET {step.get('id','?')}.{step.get('key','?')} = {step.get('value','?')} β€” skipped (optional, value not offered on this target)") elif ce.code == 404: # Transient: a set_control issued right after a structural # change (replace/add) can race the device's prepareTree and @@ -730,6 +821,16 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, except Exception: pass + # Restore: re-create any pre-existing module the scenario removed (a clear_children + # of a real container, or a replace). Combined with the created-module cleanup above, + # the board ends the run in the tree it started with β€” no residue, no lost config. + if tree_snapshot: + try: + after_state = client.get("/api/state") + _restore_tree(client, tree_snapshot, after_state) + except Exception as e: + print(f" WARN β€” couldn't restore snapshot: {e}") + # Write the scenario JSON back if anything changed: # - observed. was updated by any measure step (every run); OR # - --update-contract renegotiated the contract β€” AND the run passed diff --git a/src/core/AudioService.h b/src/core/AudioService.h index 30371728..2b588076 100644 --- a/src/core/AudioService.h +++ b/src/core/AudioService.h @@ -256,8 +256,14 @@ class AudioService : public MoonModule { /// to release() otherwise. void prepare() override { micSeat_.claim(); // first live instance wins the frame seat (claim-if-empty), any mode - if (mode == 0) reinit(); // Local only: (re)acquire the IΒ²S mic - else deinit(); // Receive / Simulate: no peripheral β€” free the IΒ²S channel + its pins + if (mode == 0) { + reinit(); // Local only: (re)acquire the IΒ²S mic (sets its own mic status) + } else { + deinit(); // Receive / Simulate: no peripheral β€” free the IΒ²S channel + its pins + clearStatus(); // the mic status is a Local-mode diagnostic; clear it so a stale + // "mic: set sckPin…" doesn't linger on the status row (Receive/Simulate + // report through the separate sync-status row instead) + } syncReinit(); } /// One-time wiring only; the mic acquire + election live in prepare(), the sole gate. diff --git a/src/core/Control.cpp b/src/core/Control.cpp index 09203101..7ca6c1e9 100644 --- a/src/core/Control.cpp +++ b/src/core/Control.cpp @@ -315,10 +315,50 @@ ApplyResult applyControlValue(const ControlDescriptor& c, mm::json::parseString(json, key, static_cast(c.ptr), maxLen); return ApplyResult::Ok; } - case ControlType::Select: + case ControlType::Select: { + // An empty option list (c.max == 0) has no valid index at all β€” don't accept a value or + // manufacture index 0 for it. Strict rejects; Lenient leaves the control untouched. + if (c.max == 0) return policy == ApplyPolicy::Strict ? ApplyResult::OutOfRange : ApplyResult::Ok; + const int hi = c.max - 1; + // A Select value may be given as the option LABEL (a string) instead of the index. This is + // what makes a catalog config board-portable: the index into a board-FILTERED option list + // varies per chip (an S3 offers fewer peripherals than a P4), but the label is stable. Match + // the string against the options and use that row; fall back to the numeric index otherwise. + // Select-only: a Select's aux IS the options array (const char* const*); Palette's aux is a + // PaletteOptionsFn (a function pointer), so it must not reach this reinterpret_cast. + // parseString silently truncates a value longer than the buffer, and a truncated label could + // spuriously equal a real option that happens to share its prefix. Guard by sizing the buffer + // past any real option label AND rejecting a value that fills it: a label that reaches the cap + // is longer than any option (or was truncated to it), so it cannot legitimately match β€” treat + // it as "no such option" rather than risk a prefix match. + char label[64] = {}; + mm::json::parseString(json, key, label, sizeof(label)); + const bool overlong = std::strlen(label) >= sizeof(label) - 1; + if (label[0]) { + auto* options = reinterpret_cast(c.aux); + if (options && !overlong) + for (int i = 0; i <= hi; i++) + if (options[i] && std::strcmp(options[i], label) == 0) + return clampInto(static_cast(c.ptr), i, 0, hi); + // A label that names no current option (a peripheral this board can't run, or one too long + // to be any real option) is not an error in Lenient policy β€” the driver keeps its default; + // Strict rejects it. + if (policy == ApplyPolicy::Strict) return ApplyResult::OutOfRange; + return ApplyResult::Ok; + } + int v = mm::json::parseInt(json, key); + if (policy == ApplyPolicy::Strict && (v < 0 || v > hi)) { + return ApplyResult::OutOfRange; + } + return clampInto(static_cast(c.ptr), v, 0, hi); + } case ControlType::Palette: { + // Palette carries a PaletteOptionsFn in aux (not an options array), so it stays numeric-index + // only β€” no label match. A string value parses to 0 via parseInt, the harmless prior behavior. + // An empty palette list (c.max == 0) has no valid index β€” reject/no-op like the Select above. + if (c.max == 0) return policy == ApplyPolicy::Strict ? ApplyResult::OutOfRange : ApplyResult::Ok; + const int hi = c.max - 1; int v = mm::json::parseInt(json, key); - const int hi = c.max > 0 ? c.max - 1 : 0; if (policy == ApplyPolicy::Strict && (v < 0 || v > hi)) { return ApplyResult::OutOfRange; } diff --git a/src/core/FilesystemModule.cpp b/src/core/FilesystemModule.cpp index fdc9039c..01fc1f8f 100644 --- a/src/core/FilesystemModule.cpp +++ b/src/core/FilesystemModule.cpp @@ -146,7 +146,8 @@ void FilesystemModule::loadSubtree(MoonModule* m) { platform::free(buf); } -void FilesystemModule::applyNode(MoonModule* m, const char* json, const char* prefix) { +// Overlay every persistable control's saved value onto the module's current control list, in list order. +void FilesystemModule::overlayControls(MoonModule* m, const char* json, const char* prefix) { char key[MAX_KEY]; auto& cs = m->controls(); for (uint8_t i = 0; i < cs.count(); i++) { @@ -155,6 +156,56 @@ void FilesystemModule::applyNode(MoonModule* m, const char* json, const char* pr std::snprintf(key, sizeof(key), "%s%s", prefix, c.name); applyValue(c, json, key); } +} + +// Restore a code-wired child's saved state when its boot index differs from the index the file recorded +// it at (a reorder of code-wired siblings). Scan the saved child entries (".type") for the +// one whose type matches `wired`, and apply that entry's subtree to it. If the file has no entry for this +// wired child (it predates the child), there is nothing to restore and the child keeps its defaults. +void FilesystemModule::applyWiredChildFromJson(MoonModule* wired, const char* json, const char* prefix) { + for (uint8_t j = 0; ; j++) { + char typeKey[MAX_KEY]; + std::snprintf(typeKey, sizeof(typeKey), "%s%u.type", prefix, static_cast(j)); + char typeName[32] = {}; + mm::json::parseString(json, typeKey, typeName, sizeof(typeName)); + if (typeName[0] == 0) return; // walked past the last saved child β€” no match, keep defaults + if (std::strcmp(typeName, wired->typeName()) != 0) continue; + char childPrefix[MAX_KEY]; + std::snprintf(childPrefix, sizeof(childPrefix), "%s%u.", prefix, static_cast(j)); + applyNode(wired, json, childPrefix); + return; + } +} + +// True when a live child of `parent` is a code-wired singleton of `typeName`. The reconcile loop uses it +// to avoid factory-creating a duplicate of a wired type-singleton whose saved entry sits at an index other +// than its boot position (already restored in place by applyWiredChildFromJson). +bool FilesystemModule::hasWiredChildOfType(const MoonModule* parent, const char* typeName) { + for (uint8_t i = 0; i < parent->childCount(); i++) { + MoonModule* c = parent->child(i); + if (c && c->isWiredByCode() && std::strcmp(c->typeName(), typeName) == 0) return true; + } + return false; +} + +void FilesystemModule::applyNode(MoonModule* m, const char* json, const char* prefix) { + char key[MAX_KEY]; + // Overlay the saved values. A module whose CONTROL SET depends on one of its own control VALUES + // (the canonical case: ParallelLedDriver's `peripheral` Select, which swaps the bus backend and + // with it the backend-owned controls β€” clockPin, dcPin, the ring cluster) needs a second pass: + // the first overlay writes `peripheral`, but the backend-owned controls are still bound to the + // DEFAULT backend's members, so their saved values land on a backend about to be discarded. So + // overlay, then rebuildControls() (which re-runs defineControls β†’ swaps the live backend to match + // the just-applied `peripheral`, re-binding the control list to the RIGHT backend's members), then + // overlay again onto the now-correct controls. rebuildControls' schema-hash gate no-ops the refire + // when nothing changed (the common case: a module with no value-dependent schema), and the second + // overlay is idempotent value writes β€” so this is safe and cheap for every module. Without it, any + // reload that rebuilds the control set (a reboot, an INT_WDT restart) silently reverts every + // backend-owned control (clockPin, the ring geometry) to its default. + overlayControls(m, json, prefix); + m->rebuildControls(); + overlayControls(m, json, prefix); + std::snprintf(key, sizeof(key), "%senabled", prefix); // Note: we can't distinguish "key absent" from "key=false" with the flat parser. // The convention: every saved file includes "enabled", so if the file exists and @@ -163,16 +214,23 @@ void FilesystemModule::applyNode(MoonModule* m, const char* json, const char* pr // they get enabled=false (matches the default-after-bad-edit behavior). m->setEnabled(mm::json::parseBool(json, key)); - // Reconcile children with the JSON's tree shape. For each position, look up - // ".type"; if it differs from the live child (or no live child - // exists), factory-create the JSON type and place it at that position. The - // newly-created child gets defineControls() here so the recursive applyNode - // below can overlay its persisted values. Phases 3+4 (setup, prepare) - // cascade into the new child automatically. - // Walk JSON child positions in order; stop when ".type" is absent. No fixed cap β€” - // the JSON itself terminates the loop. childCount_ is a uint8_t so the practical ceiling - // is 255 children per parent, far above any realistic tree. - uint8_t jsonChildCount = 0; + // Reconcile children with the JSON's tree shape. Walk each saved child entry (".type") + // and place the corresponding live child. The JSON index `i` and the live position `pos` are + // DECOUPLED: `i` always advances; `pos` advances only when a child is actually placed there. This + // decoupling is what makes a single bad entry non-fatal β€” a JSON entry that produces no live child + // (an unknown/renamed type, or a stale slot over a code-wired child) is skipped WITHOUT dropping the + // user modules the file records after it. User modules are created fresh here in file order via + // addChild, so their user-chosen order (a UI reorder) round-trips; code-wired children pre-exist and + // are skipped in place (see the isWiredByCode() branch below). + // + // The decoupling is what keeps a single bad entry from taking the rest of the tree down with it β€” the + // failure mode a naive "break on any mismatch/unknown" reconcile has, where one unresolvable entry + // drops every module the file records after it. The two entries that must skip-not-break: + // - a stale slot over a code-wired child (the file predates the wired child, or names a different + // type where it now sits): keep the wired instance, advance past it; + // - a renamed/removed module type (the documented ADR-0013 migration, e.g. a pre-consolidation + // MoonLedDriver/MultiPinLedDriver entry): that entry drops, the rest stay. + uint8_t pos = 0; for (uint8_t i = 0; ; i++) { char typeKey[MAX_KEY]; std::snprintf(typeKey, sizeof(typeKey), "%s%u.type", prefix, static_cast(i)); @@ -180,38 +238,48 @@ void FilesystemModule::applyNode(MoonModule* m, const char* json, const char* pr mm::json::parseString(json, typeKey, typeName, sizeof(typeName)); if (typeName[0] == 0) break; - MoonModule* live = m->child(i); + MoonModule* live = m->child(pos); if (!live || std::strcmp(live->typeName(), typeName) != 0) { - // Position-replace can also destroy a code-wired child if the file - // describes a different type at this slot. Bail out of further - // reconciliation rather than killing it β€” the trim loop below then - // preserves the code-wired tail, and the next save will rewrite the - // file with the current (correct) tree shape. The rest of the JSON - // past this position is dropped on this boot; that's better than - // losing a code-wired child. - if (live && live->isWiredByCode()) break; + // A code-wired child that mismatches the saved type here is a STALE SLOT (the file predates + // this code-wired child, or names a different type where it now sits β€” e.g. boot wired the + // code-wired siblings in a different order than the file recorded them). Never replace/destroy + // the wired instance: keep it and advance past it. But first restore ITS saved values β€” find + // the JSON entry that names THIS wired child's type and overlay that entry's controls, so a + // code-wired child's persisted state survives even when its saved index differs from its boot + // index (a reorder). Without this the wired child keeps its defaults on every reboot. + if (live && live->isWiredByCode()) { + applyWiredChildFromJson(live, json, prefix); + pos++; + continue; + } + // A code-wired child is a type-singleton (one per type per container). If a live wired child + // already has this entry's type, this entry IS that singleton's saved slot β€” it was restored by + // the applyWiredChildFromJson type-search above when the wired child sat at an earlier position + // (its saved index differs from its boot index). Creating here would spawn a DUPLICATE, so drop + // the entry without advancing `pos`: the singleton already stands in the live tree. + if (hasWiredChildOfType(m, typeName)) continue; MoonModule* created = ModuleFactory::create(typeName); if (!created) { - // Factory failed (type not registered). Stop here so subsequent JSON - // children don't get applied to misaligned live slots; jsonChildCount - // stays at the last successfully reconciled position, and the trim loop - // below removes any live children past that point. - break; + // Unknown/renamed type (ADR-0013 migration): the module drops. Skip this JSON entry and + // keep reconciling the rest β€” do NOT advance `pos`, so the file's later user modules still + // map to the correct live position. + continue; } created->defineControls(); if (live) { - MoonModule* old = m->replaceChildAt(i, created); + MoonModule* old = m->replaceChildAt(pos, created); if (old) { old->release(); Scheduler::deleteTree(old); } } else { m->addChild(created); } } - jsonChildCount = i + 1; char childPrefix[MAX_KEY]; std::snprintf(childPrefix, sizeof(childPrefix), "%s%u.", prefix, static_cast(i)); - applyNode(m->child(i), json, childPrefix); + applyNode(m->child(pos), json, childPrefix); + pos++; } + uint8_t jsonChildCount = pos; // live children reconciled; the trim loop keeps these, prunes the rest // Trim live children beyond what the JSON describes, EXCEPT children that // were wired by code at boot (main.cpp annotates those via markWiredByCode). // A code-wired child is preserved across persistence loads even when the diff --git a/src/core/FilesystemModule.h b/src/core/FilesystemModule.h index 5c8f9f18..bb58c129 100644 --- a/src/core/FilesystemModule.h +++ b/src/core/FilesystemModule.h @@ -151,6 +151,9 @@ class FilesystemModule : public MoonModule { void loadAll(Scheduler* s); void loadSubtree(MoonModule* m); void applyNode(MoonModule* m, const char* json, const char* prefix); + void applyWiredChildFromJson(MoonModule* wired, const char* json, const char* prefix); + static bool hasWiredChildOfType(const MoonModule* parent, const char* typeName); + void overlayControls(MoonModule* m, const char* json, const char* prefix); void applyValue(const ControlDescriptor& c, const char* json, const char* key); bool saveSubtree(MoonModule* m); void writeNode(MoonModule* m, JsonSink& sink, const char* prefix, bool firstField = true); diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp index 8d950f7a..3f3b91a4 100644 --- a/src/core/HttpServerModule.cpp +++ b/src/core/HttpServerModule.cpp @@ -1547,7 +1547,8 @@ void HttpServerModule::writeModuleMetricsJson(JsonSink& sink, MoonModule* mod, b // Apply-core: add one module under a named parent. Transport-free; returns an // OpResult. Idempotent on the id (an existing name returns Ok, "already there"). HttpServerModule::OpResult HttpServerModule::applyAddModule( - const char* typeName, const char* id, const char* parentId) { + const char* typeName, const char* id, const char* parentId, + char* outName, size_t outNameLen) { if (!typeName || typeName[0] == 0) return OpResult::BadRequest; // Top-level modules (Layouts/Layers/Drivers/Filesystem/System/Network/HttpServer) @@ -1578,6 +1579,9 @@ HttpServerModule::OpResult HttpServerModule::applyAddModule( // runs after persistence load; single source of truth. if (scheduler_) scheduler_->ensureUniqueName(mod); + // Report the FINAL name (post-disambiguation) so a caller can select/focus the new module. + if (outName && outNameLen > 0) std::snprintf(outName, outNameLen, "%s", mod->name()); + // Lifecycle in Scheduler::setup() order: defineControls() (bind buffers) β†’ // setup() (may read them) β†’ applyState() (build if effectively-enabled, else release). mod->defineControls(); @@ -1600,10 +1604,24 @@ void HttpServerModule::handleAddModule(platform::TcpConnection& conn, const char mm::json::parseString(body, "id", id, sizeof(id)); mm::json::parseString(body, "parent_id", parentId, sizeof(parentId)); - switch (applyAddModule(typeName, id, parentId)) { - case OpResult::Ok: - sendResponse(conn, 200, "application/json", "{\"ok\":true}"); + // The created module's final name (post-disambiguation) rides back in the response so the UI can + // select + focus the new module. A client-supplied `id` can contain any character (parseString + // decodes \" and \\), so the name is NOT quote-safe β€” escape it through JsonSink::writeJsonString + // (which emits its own quotes) rather than a raw %s, the same precedent as the module-status + // serialize above. A raw %s with a name containing a `"` would produce invalid JSON. + char createdName[32] = {}; + switch (applyAddModule(typeName, id, parentId, createdName, sizeof(createdName))) { + case OpResult::Ok: { + // Sized for the worst case: a 15-char name (name_[16]) fully \uXXXX-escaped (6x) + the + // ~20-char wrapper + NUL. JsonSink truncates safely if ever exceeded, never overflows. + char resp[128]; + JsonSink sink(resp, sizeof(resp)); + sink.append("{\"ok\":true,\"name\":"); + sink.writeJsonString(createdName); + sink.append("}"); + sendResponse(conn, 200, "application/json", resp); return; + } case OpResult::AlreadyExists: sendResponse(conn, 200, "application/json", "{\"ok\":true,\"note\":\"already exists\"}"); return; diff --git a/src/core/HttpServerModule.h b/src/core/HttpServerModule.h index 40d16323..2442ad35 100644 --- a/src/core/HttpServerModule.h +++ b/src/core/HttpServerModule.h @@ -197,7 +197,11 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { ReadOnly, ///< tried to write a display-only control }; /// body is a small JSON object: `{"type","id","parent_id"}` / `{"module","control","value"}`. - OpResult applyAddModule(const char* typeName, const char* id, const char* parentId); + // outName (optional): on OpResult::Ok, receives the created module's FINAL name (after + // ensureUniqueName disambiguates a collision) so a client can select/focus it. Pass its + // buffer size in outNameLen. Null β†’ not reported (the APPLY_OP transport doesn't need it). + OpResult applyAddModule(const char* typeName, const char* id, const char* parentId, + char* outName = nullptr, size_t outNameLen = 0); OpResult applySetControl(const char* moduleName, const char* controlName, const char* valueJson); /// Enumerate-then-DELETE every child of `parentName` (the catalog inject's /// replaceChildren). Returns NotFound if the parent doesn't exist, else Ok. diff --git a/src/core/MoonModule.h b/src/core/MoonModule.h index 2748b9d6..ae81cce1 100644 --- a/src/core/MoonModule.h +++ b/src/core/MoonModule.h @@ -268,11 +268,26 @@ class MoonModule { /// schema change without depending on the WS layer. Null until wired (unit tests run without it). using SchemaChangedFn = void (*)(); static void setSchemaChangedHook(SchemaChangedFn fn) { schemaChangedHook_ = fn; } + + /// Install the quiesce-render hook (the light domain points it at the encode worker: stop core 1 + /// before a structural tree mutation). A static function pointer, same decoupling as the hooks above: + /// core signals "a mutation is about to free/realloc tree nodes" without naming Drivers (a light + /// module core can't include). Null until wired (unit tests without a render worker run fine). See + /// quiesce() for WHY the per-parent quiesce() alone is not enough. + using QuiesceRenderFn = void (*)(); + static void setQuiesceRenderHook(QuiesceRenderFn fn) { quiesceRenderHook_ = fn; } /// Fire the schema-changed hook directly β€” for a change that rebuildControls() doesn't cover but that /// the client must still full-resync for. The one case: toggling a module's `enabled` (which rides the /// FULL state, never the value patch), so without this the client's cached state never learns the new /// enabled and reverts the toggle a second later. Safe when unwired (null hook β†’ no-op). static void notifySchemaChanged() { if (schemaChangedHook_) schemaChangedHook_(); } + + /// Fire the quiesce-render hook directly β€” for a NON-child-array mutation that still frees or reuses + /// memory a render worker may be reading, so it can't go through quiesceForMutation (which is the + /// add/remove/replace/move path). The one case: ParallelLedDriver's live `peripheral` swap deletes + /// the bus backend the core-1 encode worker dereferences. Same seam as notifySchemaChanged; no-op + /// when unwired. + static void notifyQuiesceRender() { if (quiesceRenderHook_) quiesceRenderHook_(); } void clearControlsRecursive() { controls_.clear(); for (uint8_t i = 0; i < childCount_; i++) children_[i]->clearControlsRecursive(); @@ -482,10 +497,25 @@ class MoonModule { /// Idempotent and safe to call when no worker is running. virtual void quiesce() {} + /// The full pre-mutation quiesce, called by the four structural mutators below (addChild / + /// removeChild / replaceChildAt / moveChildTo). Two workers + /// can be reading the tree, so BOTH must be stopped before a node is freed or the child array is + /// realloc'd: (1) `this->quiesce()` β€” a worker THIS module owns and that iterates ITS OWN children + /// (Drivers, when its parent is mutated); and (2) the render worker via the quiesce-render hook β€” the + /// core-1 encode worker ticks the drivers, and a driver walks the WHOLE tree (a layout, a layer), + /// so mutating ANY node β€” not just the worker-owner's own child array β€” is a use-after-free for it. + /// The per-parent quiesce() alone missed this: replacing a *layout* quiesces Layouts (no worker) + /// while the render worker is mid-walk of that layout (the LoadProhibited crash this fixes). The + /// hook reaches the render worker wherever it lives; both are idempotent and no-ops when idle. + void quiesceForMutation() { + quiesce(); + if (quiesceRenderHook_) quiesceRenderHook_(); + } + /// Generic children β€” grows on demand, only allocates during setup. bool addChild(MoonModule* child) { if (!child) return false; - quiesce(); // a worker may be iterating children_; the realloc below would pull it out from under it + quiesceForMutation(); // a worker may be iterating children_; the realloc below would pull it out from under it if (childCount_ == childCapacity_) { uint8_t newCap = childCapacity_ == 0 ? 4 : childCapacity_ * 2; auto** newArr = new MoonModule*[newCap]; @@ -500,16 +530,17 @@ class MoonModule { } bool removeChild(MoonModule* child) { - quiesce(); // the caller release()s + deleteTree()s `child` next; a worker must not be inside its tick() - for (uint8_t i = 0; i < childCount_; i++) { - if (children_[i] == child) { - child->setParent(nullptr); - for (uint8_t j = i; j + 1 < childCount_; j++) children_[j] = children_[j + 1]; - childCount_--; - return true; - } - } - return false; + // Locate the child FIRST β€” a not-found removeChild is a no-op and must not quiesce the render + // worker (which would needlessly disengage the split until the next prepare). Only quiesce once + // we know we are about to actually mutate the array + the caller frees the child. + uint8_t idx = 0; + for (; idx < childCount_; idx++) if (children_[idx] == child) break; + if (idx >= childCount_) return false; + quiesceForMutation(); // the caller release()s + deleteTree()s `child` next; a worker must not be inside its tick() + child->setParent(nullptr); + for (uint8_t j = idx; j + 1 < childCount_; j++) children_[j] = children_[j + 1]; + childCount_--; + return true; } /// Replace child at position i with fresh. Caller owns lifecycle of the removed @@ -517,7 +548,7 @@ class MoonModule { /// Used by FilesystemModule at load time to swap a child whose type differs from /// the persisted JSON; the caller tears down + Scheduler::deleteTree's the old child. MoonModule* replaceChildAt(uint8_t i, MoonModule* fresh) { - quiesce(); // same hazard as removeChild: the caller tears down + deletes the child we swap out + quiesceForMutation(); // same hazard as removeChild: the caller tears down + deletes the child we swap out if (i >= childCount_ || !fresh) return nullptr; MoonModule* old = children_[i]; if (old) old->setParent(nullptr); @@ -532,20 +563,25 @@ class MoonModule { /// the caller's follow-up Scheduler::prepareTree() rebuilds any order-dependent LUT. bool moveChildTo(MoonModule* child, uint8_t newIndex) { if (newIndex >= childCount_) return false; - for (uint8_t i = 0; i < childCount_; i++) { - if (children_[i] != child) continue; - if (i == newIndex) return false; // no-op - if (newIndex > i) { - // Shift left to fill the gap - for (uint8_t j = i; j < newIndex; j++) children_[j] = children_[j + 1]; - } else { - // Shift right to make room - for (uint8_t j = i; j > newIndex; j--) children_[j] = children_[j - 1]; - } - children_[newIndex] = child; - return true; + // Resolve the child and reject the no-op (not found, or already at newIndex) BEFORE quiescing β€” + // a move that changes nothing must not disengage the render split. Only an actual permutation + // needs the guard below. + uint8_t idx = 0; + for (; idx < childCount_; idx++) if (children_[idx] == child) break; + if (idx >= childCount_ || idx == newIndex) return false; + // The fourth structural mutator: permuting children_ under an index-based worker loop can tick a + // child twice or skip one (no free, so not a use-after-free β€” but the same data-race class the + // add/remove/replace guards close). Quiesce like the others before touching the array. + quiesceForMutation(); + if (newIndex > idx) { + // Shift left to fill the gap + for (uint8_t j = idx; j < newIndex; j++) children_[j] = children_[j + 1]; + } else { + // Shift right to make room + for (uint8_t j = idx; j > newIndex; j--) children_[j] = children_[j - 1]; } - return false; + children_[newIndex] = child; + return true; } uint8_t childCount() const { return childCount_; } @@ -681,6 +717,7 @@ class MoonModule { // Schema-changed hook: one function pointer for the whole process (like the persistence // noteDirty hook), poked by rebuildControls() so the WS layer resyncs on any schema change. static inline SchemaChangedFn schemaChangedHook_ = nullptr; + static inline QuiesceRenderFn quiesceRenderHook_ = nullptr; }; } // namespace mm diff --git a/src/core/SystemModule.h b/src/core/SystemModule.h index 321b764a..67af2737 100644 --- a/src/core/SystemModule.h +++ b/src/core/SystemModule.h @@ -115,12 +115,28 @@ class SystemModule : public MoonModule { static_cast(chipFlashVal_ / (1024 * 1024))); } + // Apply the persisted (or default) log level to the platform logger now, so a device that + // booted with a saved Warn/Error level is quiet from the first tick rather than only after + // the user touches the control. The main loop's first-60 s override keeps Info-level output + // (the installer's MM_IP read) alive regardless of this. + applyLogLevel(); + // Chain to base so children (the wired-by-code System modules β€” Tasks, I2cScan) // get their setup() β€” a child initialises its state here. Overriding // setup() shadows the base default that would otherwise propagate. MoonModule::setup(); } + /// The persisted serial log level. The main loop reads this to decide whether to emit the + /// once-a-second KPI tick line (Info or above), so a resting device at Warn stays off the wire. + platform::LogLevel logLevel() const { return static_cast(logLevel_); } + + /// A live log-level change applies immediately (no rebuild): push it to the platform logger. + /// Changing verbosity does not reshape any derived state, so this is onControlChanged, not prepare. + void onControlChanged(const char* controlName) override { + if (std::strcmp(controlName, "logLevel") == 0) applyLogLevel(); + } + void defineControls() override { // Platform-derived totals queried here (idempotent, no I/O) so the conditionals that // gate the Progress controls see real values rather than waiting on setup(). @@ -189,6 +205,12 @@ class SystemModule : public MoonModule { // Persisted so it survives a reboot; the UI honors it client-side (see the `advanced` flag on // Control) β€” nothing in the firmware reads it, so it needs no rebuild trigger. controls_.addBool("expertMode", expertMode_); + // Serial log level: how chatty the device is on the UART. Default Warn keeps the once-a-second + // KPI tick line off (a status LED that blinks on serial TX rests quiet) while real warnings and + // errors still print. Applied to the platform logger on change (see applyLogLevel); the KPI line + // is gated in the main loop. Advanced β€” a diagnostics knob, not a casual-user control. + controls_.addSelect("logLevel", logLevel_, logLevelOptions_, 6); + controls_.setAdvanced(controls_.count() - 1); // WiFi co-processor (P4 + on-board C6) firmware read-out. Gated at compile // time on hasWifiCoprocessor, so the whole control β€” and the snprintf/query // cost β€” vanishes on native-radio builds (classic/S3/desktop) and the @@ -304,6 +326,22 @@ class SystemModule : public MoonModule { // (dev/tuning readouts and knobs a casual user doesn't need β€” e.g. MoonLed's ring diagnostics and // manual geometry). One flag the whole system's UI composes against; no module reads System's state. bool expertMode_ = false; + // Push the current level to the platform logger. Clamps to the valid enum range so a corrupt + // persisted value can't index past Verbose. + void applyLogLevel() { + uint8_t lvl = logLevel_ > static_cast(platform::LogLevel::Verbose) + ? static_cast(platform::LogLevel::Verbose) : logLevel_; + platform::setLogLevel(static_cast(lvl)); + } + + // Serial log verbosity, persisted, default Warn. Controls how chatty the device is on the wire: + // at Warn the once-a-second KPI tick line is suppressed (no serial write, so a status LED that + // flickers on UART TX rests quiet) while ESP_LOGW/ESP_LOGE warnings and errors still print. The + // main loop reads logLevel() to gate the KPI line; onControlChanged re-applies it to the platform + // logger on change. The first 60 s of uptime always logs at Info regardless (the web installer + // reads MM_IP off the tick line just after flash). Stored as the raw enum value for addSelect. + uint8_t logLevel_ = static_cast(platform::LogLevel::Warn); + static constexpr const char* logLevelOptions_[] = {"None", "Error", "Warn", "Info", "Debug", "Verbose"}; // Physical-hardware identity (catalog entry name). 32-byte buffer fits the longest // entry ("Olimex ESP32-Gateway Rev G" = 26) with headroom; the Improv RPC handler // caps str_len against this size dynamically. diff --git a/src/core/TasksModule.h b/src/core/TasksModule.h index 11ea000b..e3b38a33 100644 --- a/src/core/TasksModule.h +++ b/src/core/TasksModule.h @@ -9,6 +9,7 @@ #include "core/JsonSink.h" // writeListRow emits its row as JSON into the sink #include "platform/platform.h" // taskSnapshot / TaskInfo β€” the RTOS task view (behind the boundary) +#include // std::sort β€” stable row order so the list doesn't jump each refresh #include #include #include // strcmp β€” match a task row to the render task @@ -79,6 +80,27 @@ class TasksModule : public MoonModule { void refresh() { count_ = static_cast(platform::taskSnapshot(rows_, kMaxTasks)); + // uxTaskGetSystemState walks the RTOS ready/blocked lists, so a task's position in the + // snapshot shifts as its state changes β€” the SAME task lands on a different row each refresh, + // and the once-a-second list re-render makes the rows jump so you can't read them. Sort so + // every task holds a fixed row (only the live cpu/stack values update in place), AND float + // projectMM's OWN tasks to the top (the render task "main" and the "mm"-prefixed workers like + // mmEncode / mmSnap β€” the ones the user is here to watch), sinking the RTOS system tasks + // (IDLE, Tmr, ipc, esp_timer, …) below. Alphabetical within each group. + std::sort(rows_, rows_ + count_, [](const platform::TaskInfo& a, const platform::TaskInfo& b) { + const bool oursA = isProjectMMTask(a.name), oursB = isProjectMMTask(b.name); + if (oursA != oursB) return oursA; // our tasks first + return std::strcmp(a.name, b.name) < 0; // then alphabetical, stable + }); + } + + // A projectMM-created task: the render task (whatever platform::renderTaskName() reports β€” the + // same seam writeListRowDetail uses to nest the modules, NOT a hardcoded "main") or a worker we + // spawn (our convention names them with an "mm" prefix β€” mmEncode, mmSnap). Everything else is an + // RTOS/system task. + static bool isProjectMMTask(const char* name) { + const char* render = platform::renderTaskName(); + return (render[0] && std::strcmp(name, render) == 0) || std::strncmp(name, "mm", 2) == 0; } uint8_t listRowCount() const override { return count_; } void writeListRow(JsonSink& sink, uint8_t row) const override { diff --git a/src/light/drivers/DriverBase.h b/src/light/drivers/DriverBase.h index 89982507..ef3e1f9a 100644 --- a/src/light/drivers/DriverBase.h +++ b/src/light/drivers/DriverBase.h @@ -20,6 +20,7 @@ #include "light/layers/Buffer.h" #include "light/layers/Layer.h" #include "light/drivers/Correction.h" +#include "light/drivers/LedPeripheral.h" // LedHwBlock β€” the peripheral-block claim guard's vocabulary #include "light/drivers/LightPresetsModule.h" // the shared preset library a driver references by id #include "platform/platform.h" @@ -50,6 +51,12 @@ class DriverBase : public MoonModule { ModuleRole role() const override { return ModuleRole::Driver; } virtual void setSourceBuffer(Buffer* buf) = 0; + /// The hardware peripheral block this driver drives, for the parallel-driver claim guard (two live + /// drivers on one block corrupt each other). Only ParallelLedDriver overrides it; every other driver + /// (RMT, NetworkSend, Hue, Preview) drives no shared parallel block and keeps None. Virtual, not + /// RTTI β€” ESP32 builds compile -fno-rtti, so the guard reads siblings through this, never a cast. + virtual LedHwBlock hwBlock() const { return LedHwBlock::None; } + /// Template method: every driver card leads with the per-driver output correction /// (localBrightness / lightPreset / whiteMode / Custom offsets), added once here in the base /// so no driver re-implements the placement (the No-duplication rule) β€” then the driver's own @@ -143,9 +150,9 @@ class DriverBase : public MoonModule { /// controls chains to this so it doesn't re-implement the correction branch (the /// Complexity-lives-in-core rule: the correction rule lives here, once, for every driver). void onControlChanged(const char* name) override { - // A preset Select change: map the chosen INDEX to a stable preset id (what we store + + // A lightPreset Select change: map the chosen INDEX to a stable preset id (what we store + // persist), so the reference survives a later reorder/delete of other presets. - if (std::strcmp(name, "preset") == 0) { + if (std::strcmp(name, "lightPreset") == 0) { if (auto* lib = LightPresetsModule::active()) { presetId_ = lib->idAt(presetSel_); std::snprintf(presetRef_, sizeof(presetRef_), "%s", lib->nameAt(presetSel_)); // persist the name @@ -259,7 +266,7 @@ class DriverBase : public MoonModule { void defineCorrectionControls() { controls_.addUint8("localBrightness", localBrightness_, 0, 255); buildPresetOptions(); // fill presetOptions_ from the library, sync id/sel/ref - controls_.addSelect("preset", presetSel_, presetOptions_, presetOptionCount_); + controls_.addSelect("lightPreset", presetSel_, presetOptions_, presetOptionCount_); controls_.addSelect("whiteMode", whiteMode_, kWhiteModeOptions, kWhiteModeCount); // whiteMode only applies when the REFERENCED preset carries a channel apply() synthesises // from RGB (White / WarmWhite / Yellow / UV) β€” there's nothing to synthesise on a plain @@ -267,8 +274,8 @@ class DriverBase : public MoonModule { // tracks the live reference. auto* lib = LightPresetsModule::active(); controls_.setHidden(controls_.count() - 1, !(lib && lib->presetHasSynthChannel(presetId_))); - // The durable reference (the preset NAME) persists but isn't shown β€” the preset Select above - // is the user-facing control; presetRef_ just carries the reference across a reboot. + // The durable reference (the preset NAME) persists but isn't shown β€” the lightPreset Select + // above is the user-facing control; presetRef_ just carries the reference across a reboot. controls_.addText("presetRef", presetRef_, sizeof(presetRef_)); controls_.setHidden(controls_.count() - 1, true); } @@ -281,7 +288,7 @@ class DriverBase : public MoonModule { /// True if `name` is one of the correction controls β€” a driver folds this into its /// affectsPrepare() and its correction rebuilds in onControlChanged (both handled by DriverBase). static bool isCorrectionControl(const char* name) { - return std::strcmp(name, "preset") == 0 || std::strcmp(name, "localBrightness") == 0 + return std::strcmp(name, "lightPreset") == 0 || std::strcmp(name, "localBrightness") == 0 || std::strcmp(name, "whiteMode") == 0; } diff --git a/src/light/drivers/Drivers.h b/src/light/drivers/Drivers.h index 6f8bd887..54735e06 100644 --- a/src/light/drivers/Drivers.h +++ b/src/light/drivers/Drivers.h @@ -109,6 +109,20 @@ class Drivers : public MoonModule { /// is the only place that can't be skipped. ~Drivers() override { stopEncodeTask(); } + /// Stop the core-1 encode worker so a STRUCTURAL TREE MUTATION (a module replace / delete / add) can + /// free tree nodes without the worker dereferencing them mid-tick. The worker ticks the driver + /// children, and a driver walks the whole Layouts/Layer tree (PreviewDriver::sendFrame β†’ + /// Layouts::forEachCoord), so freeing ANY layout/layer/driver node while core 1 runs is a + /// use-after-free β€” a LoadProhibited fault (e.g. replacing a layout on a running split device). The + /// mutation path (HttpServerModule) calls this before the free; the trailing prepareTree() re-engages + /// the split. Idempotent + safe when the split is off (stopEncodeTask guards on the task handle). + void quiesceRenderSplit() { + stopEncodeTask(); + renderSplitActive_ = false; + } + /// Reach the live Drivers (the one that owns the encode worker) to quiesce it around a mutation. + static Drivers* active() { return ActiveInstance::active(); } + /// Global brightness (0–255). Scales every channel through a 256-entry LUT /// (`(v Γ— brightness) / 255`); changing it rebuilds only the LUT on the cheap /// `onControlChanged` tier β€” no pipeline realloc, so the slider is fluent. Gamma / diff --git a/src/light/drivers/LedPeripheral.h b/src/light/drivers/LedPeripheral.h new file mode 100644 index 00000000..1558d693 --- /dev/null +++ b/src/light/drivers/LedPeripheral.h @@ -0,0 +1,136 @@ +#pragma once + +#include +#include +#include "core/Control.h" // ControlList β€” a backend appends its own controls into the shared list +#include "platform/platform.h" // RmtLoopbackResult + +namespace mm { + +class ParallelLedDriver; // the orchestrator; a backend reads shared state through this back-pointer + +/// The physical peripheral block a backend drives. Two backends on the SAME block conflict β€” the chip +/// has exactly one of each β€” so the orchestrator refuses to bring up a second driver already claiming a +/// block a sibling holds. Note esp_lcd-i80 (on the S3/P4) and MoonI80 BOTH drive LcdCam: they are the +/// same silicon reached two ways, so they conflict with each other, not only with themselves. The +/// classic-ESP32 i80 is the I2S peripheral, a different block. +enum class LedHwBlock : uint8_t { None = 0, LcdCam, I2s, Parlio }; + +/// A parallel-WS2812 output peripheral, behind a runtime strategy interface. +/// +/// `ParallelLedDriver` (the one MoonModule) owns the controls, lifecycle, tick, and the shared +/// slice/encode/double-buffer/expander/loopback machinery, and drives ONE `LedPeripheral` chosen at +/// runtime (Parlio, esp_lcd i80, or MoonI80 own-GDMA). The peripheral supplies only the variant +/// operations β€” bring the bus up, hand back its DMA buffer, transmit a frame, tear down β€” plus a few +/// static descriptors (lane count, expander support, bus-width rounding) as virtuals, so the +/// orchestrator reads them through the one `LedPeripheral*` rather than a compile-time type. +/// +/// **Not a hot-path virtual boundary.** Every method here is called per-FRAME or per-reinit, never +/// per-light: the per-light encode operates on the raw `uint8_t*` `busBuffer()` hands back and never +/// calls into the peripheral. One vcall per frame against ~thousands of Β΅s of frame work is free β€” the +/// dispatch that mattered (per-light) stays a direct call inside the shared encode. +/// +/// **Shared state via a back-pointer.** A backend reaches the orchestrator's parsed lane list, latch +/// bit, correction, and loopback pin through `owner()` (set once by `attach()`), replacing what CRTP +/// inheritance gave for free. The orchestrator exposes exactly those as public const accessors. +/// +/// Prior art: the Strategy / pluggable-backend pattern; the projectMM `ListSource` / `DevicePlugin` +/// adapter shape (a generic owner + a variant object that travels with its own state). +class LedPeripheral { +public: + virtual ~LedPeripheral() = default; + + /// Bind the peripheral to its orchestrator. Called once, right after construction, before any + /// bus operation. The backend reads shared state (bus pin list, latch, correction, loopback pin) + /// through this pointer. + void attach(ParallelLedDriver* owner) { owner_ = owner; } + + // --- Static descriptors: per-peripheral constants the orchestrator reads through the interface --- + /// Parallel lanes this peripheral's silicon provides on the current chip (0 = not this chip). + virtual uint8_t lanesAvailable() const = 0; + /// Can this peripheral host the 74HCT595 pin expander? (Needs a DMA that reaches PSRAM.) + virtual bool supportsPinExpander() const = 0; + /// Can this peripheral run the async double-buffer (a second whole-frame buffer, encode overlaps + /// wire)? Default yes β€” i80/Parlio route through a real transaction queue (esp_lcd / the Parlio + /// driver), so a second in-flight transfer is handled for us. MoonI80 owns its GDMA with no queue, + /// and its hand-rolled two-buffer completion handshake races; it overrides this false and runs + /// single-buffer (its speed comes from the ring, not from double-buffering a whole frame). + virtual bool supportsDoubleBuffer() const { return true; } + /// Does the bus width round up to a power of two (8/16), or is it the exact pin count? + virtual bool powerOfTwoBus() const = 0; + /// The status message when bus init fails on this peripheral. + virtual const char* initFailMsg() const = 0; + /// Must the loopback self-test build a full-width bus (true) or can it run on a private 1-lane + /// unit (false)? esp_lcd i80 / MoonI80 need the full width; Parlio can do a single lane. + virtual bool loopbackFullWidth() const = 0; + /// The physical peripheral block this backend drives β€” the orchestrator's claim guard refuses two + /// live drivers on the same block. On the classic ESP32 the esp_lcd-i80 backend is the I2S block; + /// on the S3/P4 it (and MoonI80) is LcdCam. + virtual LedHwBlock hwBlock() const = 0; + + // --- Required core (no default; every backend implements) --- + /// Create the bus + its DMA buffer(s) sized for `frameBytes`; `wantSecondBuffer` requests the + /// async double-buffer's second frame (allocated only if it fits). Returns whether init succeeded. + virtual bool busInit(size_t frameBytes, bool wantSecondBuffer) = 0; + /// Tear down the bus and its DMA buffer(s). + virtual void busDeinit() = 0; + /// DMA buffer `i` (0/1) the encoder writes into; buffer 1 is null in single-buffer mode. + virtual uint8_t* busBuffer(uint8_t i) = 0; + /// Per-buffer byte capacity (fixed at bus creation; both buffers equal). + virtual size_t busCapacity() const = 0; + /// Kick off the autonomous transfer of the first `bytes` of DMA buffer `i`; returns whether it + /// started. + virtual bool busTransmit(uint8_t i, size_t bytes) = 0; + /// Block up to `ms` for buffer `i`'s in-flight transfer to complete. + virtual bool busWait(uint8_t i, uint32_t ms) = 0; + /// The most recent DMA transfer's wire time (Β΅s) β€” the WS2812 output floor. + virtual uint32_t busLastTransmitUs() const = 0; + /// Run the loopback self-test on this peripheral (each builds its own bus, per loopbackFullWidth). + virtual platform::RmtLoopbackResult busLoopback(const uint8_t* frame, size_t frameBytes, + size_t dataBytes, uint8_t rowBits) = 0; + + // --- Ring cluster (default: no ring β€” only the MoonI80 backend overrides) --- + /// Bring the bus up as a streaming ring for `totalRows` rows of `rowBytes`; false if it won't fit. + virtual bool busInitRing(size_t /*rowBytes*/, uint32_t /*totalRows*/) { return false; } + /// Send one frame on the ring (prime + arm + ISR refill). False if the ring isn't up. + virtual bool busTransmitRing() { return false; } + /// Is the live bus a streaming ring? + virtual bool busIsRing() const { return false; } + /// Should reinit build a ring for the current config instead of the whole-frame path? + virtual bool wantsRing() const { return false; } + /// The ring's active-mode label for the status line, or nullptr for the whole-frame path. + virtual const char* busRingMode() const { return nullptr; } + /// Append this peripheral's ring controls into the shared list. Default: none. + virtual void addRingControls(ControlList& /*controls*/) {} + /// Refresh any peripheral-specific read-only KPIs (the ring diagnostic). Default: none. + virtual void refreshBusKpi() {} + /// Is the core-0 fork-join snapshot helper up (dual-core prime)? Default: no helper. + virtual bool snapHelperReady() const { return false; } + + // --- Bus-pin cluster (default: no extra pins β€” Parlio) --- + /// Append this peripheral's bus-pin controls (WR/DC clock pins) into the shared list. Default: none. + virtual void addBusControls(ControlList& /*controls*/) {} + /// Does a change to control `name` require a bus rebuild (a bus pin changed)? Default: no. + virtual bool busControlTriggersBuild(const char* /*name*/) const { return false; } + /// Snapshot the current bus pins so extraBusPinsCurrent can detect a later change. Default: none. + virtual void recordBusPins() {} + /// Are the recorded bus pins still current (no un-applied change)? Default: always current. + virtual bool extraBusPinsCurrent() const { return true; } + /// A per-peripheral fatal validation (returns a status message) run before bus init. Default: ok. + virtual const char* validateBusFatal() const { return nullptr; } + /// A per-peripheral lane-pin validation (returns a warning) β€” e.g. a data pin colliding with WR. + virtual const char* validateBusPins(const uint16_t* /*lanes*/, uint8_t /*n*/) const { return nullptr; } + /// The GPIO the bus parks spare (unused) lanes on (only reached when powerOfTwoBus rounds the + /// bus wider than the data-pin count β€” i80/MoonI80, which both override this to their WR pin). + /// The default 0 is never used by a peripheral whose bus is the exact pin count (Parlio, + /// powerOfTwoBus=false, never pads), so no owner lookup is needed here. + virtual uint16_t clockPinForBus() const { return 0; } + /// The whole-frame DMA byte budget: 0 = "no bound" (PSRAM-capable). A bounded peripheral (the + /// classic-ESP32 i80 = internal-RAM-only I2S) returns a positive ceiling. Default: no bound. + virtual size_t dmaBudgetBytes() const { return 0; } + +protected: + ParallelLedDriver* owner_ = nullptr; +}; + +} // namespace mm diff --git a/src/light/drivers/LightPresetsModule.h b/src/light/drivers/LightPresetsModule.h index 2dfe1940..9fc8a245 100644 --- a/src/light/drivers/LightPresetsModule.h +++ b/src/light/drivers/LightPresetsModule.h @@ -151,8 +151,13 @@ class LightPresetsModule : public MoonModule, public ListSource { void writeListRow(JsonSink& sink, uint8_t row) const override { const Preset& p = presets_[row]; const uint8_t* roles = roleAt(p); - sink.appendf("{\"id\":%lu,\"name\":\"%s\",\"channels\":%u,\"roles\":[", - static_cast(p.id), p.name, static_cast(p.channelCount)); + // name via writeJsonString (escapes `"` / `\` / control bytes), NOT raw %s: this row IS the + // persisted form, so a name with a quote emitted raw would produce malformed JSON that fails to + // parse on the next boot β€” silently wiping every custom preset. (DevicesModule::writeListRow + // writes its name the same way for the same reason.) + sink.appendf("{\"id\":%lu,\"name\":", static_cast(p.id)); + sink.writeJsonString(p.name); + sink.appendf(",\"channels\":%u,\"roles\":[", static_cast(p.channelCount)); for (uint8_t c = 0; c < p.channelCount; c++) sink.appendf("%s%u", c ? "," : "", static_cast(roles[c])); sink.append("]"); @@ -180,7 +185,9 @@ class LightPresetsModule : public MoonModule, public ListSource { const Preset& p = presets_[row]; const uint8_t* roles = roleAt(p); sink.append("{\"fields\":["); - sink.appendf("{\"name\":\"name\",\"type\":\"text\",\"value\":\"%s\"},", p.name); + sink.append("{\"name\":\"name\",\"type\":\"text\",\"value\":"); + sink.writeJsonString(p.name); // escape the value, same reason as writeListRow + sink.append("},"); sink.appendf("{\"name\":\"channels\",\"type\":\"uint8\",\"value\":%u,\"min\":1,\"max\":255}", static_cast(p.channelCount)); for (uint8_t c = 0; c < p.channelCount; c++) { @@ -299,6 +306,13 @@ class LightPresetsModule : public MoonModule, public ListSource { count_++; } rebuildPool(); + // The role pool is heap-backed; on an out-of-memory boot (fragmented / exhausted heap on ESP32) + // rolePool_.resize() leaves data() null. Writing roles through a null base would be a hard fault, + // so degrade to an empty list instead β€” the device runs, setup()'s count_==0 gate re-seeds the + // built-ins, and the customs are simply not restored this boot. (Desktop malloc effectively never + // fails at these sizes, so this only trips on a memory-tight device β€” exactly where a crash is + // least acceptable.) + if (!rolePool_.data() && count_ > 0) { count_ = 0; return true; } // Second pass: fill each preset's roles now that the pool is sized. for (int r = 0, idx = 0; r < n && idx < count_; r++, idx++) { const mm::json::JsonNode* row = mm::json::element(doc, arr, r); @@ -306,8 +320,13 @@ class LightPresetsModule : public MoonModule, public ListSource { uint8_t* dst = roleAtMut(presets_[idx]); const int rn = (roles && roles->type == mm::json::JsonType::Array) ? mm::json::arraySize(doc, roles) : 0; - for (uint8_t c = 0; c < presets_[idx].channelCount; c++) - dst[c] = (c < rn) ? static_cast(mm::json::readInt(mm::json::element(doc, roles, c), 0)) : 0; + // Clamp each persisted role to the valid ChannelRole range, matching setListRowField's + // validation β€” a hand-edited / corrupt file can carry an out-of-range byte, and an unclamped + // cast would store a value the UI mis-renders and the Correction silently drops. + for (uint8_t c = 0; c < presets_[idx].channelCount; c++) { + const long rv = (c < rn) ? mm::json::readInt(mm::json::element(doc, roles, c), 0) : 0; + dst[c] = (rv < 0 || rv >= kChannelRoleCount) ? 0 : static_cast(rv); + } } return true; } diff --git a/src/light/drivers/MoonLedDriver.h b/src/light/drivers/MoonLedDriver.h index dcd38856..fb930db3 100644 --- a/src/light/drivers/MoonLedDriver.h +++ b/src/light/drivers/MoonLedDriver.h @@ -1,25 +1,25 @@ #pragma once -#include "light/drivers/ParallelLedDriver.h" // shared CRTP body +#include "light/drivers/ParallelLedDriver.h" // shared driver body + LedPeripheral #include "platform/platform.h" #include // the parallel-snapshot helper join flags namespace mm { -/// Output driver: parallel WS2812B on the **LCD_CAM** peripheral (ESP32-S3 / -P4), driven by **our own -/// DMA code** instead of ESP-IDF's `esp_lcd`. Same peripheral, pins and wire contract as -/// [MultiPinLedDriver](MultiPinLedDriver.md); the difference is underneath, and it buys two things -/// `esp_lcd` cannot give β€” a frame **streamed** rather than held whole, and a **74HCT595 pin expander** -/// (one GPIO driving 8 strands). +/// A `LedPeripheral` backend: parallel WS2812B on the **LCD_CAM** peripheral (ESP32-S3 / -P4 / -S31), driven by +/// **our own DMA code** instead of ESP-IDF's `esp_lcd`. Same peripheral, pins and wire contract as the +/// `i80` backend (`I80Peripheral`); the difference is underneath, and it buys two things `esp_lcd` cannot +/// give β€” a frame **streamed** rather than held whole, and a **74HCT595 pin expander** (one GPIO driving +/// 8 strands). /// /// **Streamed, not held:** the DMA refills a small pool of internal buffers behind the read head, so RAM -/// stops scaling with strand length (`useRing` picks this path; `ringRows`/`ringBufs` size it). **Both -/// paths ship:** `MultiPinLedDriver` is the memory-capped **reference**, this is the streaming -/// **challenger**, and `useRing` A/Bs them on the same board with no reflash. LCD_CAM only β€” the classic -/// ESP32's i80 is the I2S peripheral, a different backend. Everything above the DMA (slicing, the fused -/// 3-slot encode, the async double-buffer, loopback, the `frameTime` KPI, the dead-frame guard) is -/// inherited from ParallelLedDriver, so this class is nearly all one-liners. +/// stops scaling with strand length (`useRing` picks this path; `ringRows`/`ringBufs` size it). The `i80` +/// backend is the memory-capped **reference**, this is the streaming **challenger**, and switching the +/// `peripheral` control A/Bs them on the same board with no reflash. LCD_CAM only β€” the classic ESP32's +/// i80 is the I2S peripheral, a separate backend. Everything above the DMA (slicing, the fused 3-slot +/// encode, the async double-buffer, loopback, the `frameTime` KPI, the dead-frame guard) lives in +/// ParallelLedDriver, so this backend is nearly all one-liners. /// /// The deep dives are under *More info*, below the attribute/method lists: /// @xref{why-our-own-dma-driver-below-the-read-head|why our own DMA driver}, @@ -136,14 +136,15 @@ namespace mm { /// `putdefaultones()` prefill has our own counterpart; the transpose is our own SWAR). He runs the S3 /// shift clock at ~19.2 MHz; we default to the same reliability point (20 MHz, a 28.8 Β΅s/light budget) /// with the `shiftOverclock` switch (26.67 MHz, 21.6 Β΅s/light) for short-wired rigs (see the control). -class MoonLedDriver : public ParallelLedDriver { +class MoonI80Peripheral : public LedPeripheral { public: // Data pins + loopback pin default to UNSET, for the same reason as the sibling: they are // user-soldered, so a hard-coded default would be a guess that could drive a pin the user - // committed elsewhere. The base declares pins="" / loopbackRxPin=-1, so nothing is needed here. + // committed elsewhere. The orchestrator declares pins="" / loopbackRxPin=-1, so nothing is + // needed here. /// WR β€” the pixel clock β€” and it is needed **only by a 74HCT595 expander**, which is why it is the - /// one bus control this driver keeps. + /// one bus control this backend keeps. /// /// WR toggles once per bus word in hardware, which is exactly what a '595's SRCLK needs: the pixel /// clock IS the shift clock. That is why the expander costs zero DMA bytes for its clock, and why @@ -250,107 +251,122 @@ class MoonLedDriver : public ParallelLedDriver { /// `sizeof` bounds the `snprintf`. char ringDbgStr_[176] = "β€”"; - // --- CRTP hooks the base calls (all non-virtual; no vtable) --- + // --- LedPeripheral descriptors --- - /// LCD_CAM lanes on this chip (0 = none, and then the base's guards make the driver inert). + /// LCD_CAM lanes on this chip (0 = none, and then the orchestrator's guards make the driver inert). /// Unlike the sibling this does NOT add `i2sLanes`: the classic ESP32's i80 is the I2S peripheral, /// which this backend does not implement. - static constexpr uint8_t lanesAvailable() { return platform::lcdLanes; } + uint8_t lanesAvailable() const override { return platform::lcdLanes; } /// The i80 bus width is 8 or 16 β€” a hardware fact (`lcd_ll_set_data_wire_width` takes nothing else). - /// The PIN count stays free: configure only the pins that drive something and the base rounds the bus - /// up around them, parking the spare lanes on WR (which the peripheral already drives, and nothing - /// reads). Parlio sets this false β€” its bus width IS its pin count. - static constexpr bool kPowerOfTwoBus = true; + /// The PIN count stays free: configure only the pins that drive something and the orchestrator rounds + /// the bus up around them, parking the spare lanes on WR (which the peripheral already drives, and + /// nothing reads). Parlio's backend sets this false β€” its bus width IS its pin count. + bool powerOfTwoBus() const override { return true; } /// The loopback cannot build a 1-lane private bus, so it rebuilds the full-width bus and carries /// the pattern on lane 0 β€” the test frame must therefore be encoded at the operational bus width. - static constexpr bool kLoopbackFullWidth = true; + bool loopbackFullWidth() const override { return true; } + /// MoonI80 programs LCD_CAM directly (its own GDMA below esp_lcd), so it claims the LcdCam block β€” + /// the same block the esp_lcd-i80 backend uses on the LCD_CAM chips (S3/P4/S31), hence the two can't run together. + LedHwBlock hwBlock() const override { return LedHwBlock::LcdCam; } /// Status text when the bus will not come up, so the cause is on screen rather than in a serial log. /// The two real causes are named: a pin the peripheral cannot route, or no DMA-reachable memory for /// the frame (or the ring's pool). - static constexpr const char* kInitFailMsg = "MoonI80 bus init failed β€” check pins / memory"; - /// The expander needs a backend that can stream its Γ—8 frame; LCD_CAM is it, and this driver is - /// LCD_CAM-only, so the answer is simply "wherever this driver runs at all". - static constexpr bool kSupportsPinExpander = platform::lcdLanes > 0; + const char* initFailMsg() const override { return "MoonI80 bus init failed β€” check pins / memory"; } + /// The expander needs a backend that can stream its Γ—8 frame; LCD_CAM is it, and this backend is + /// LCD_CAM-only, so the answer is simply "wherever this backend runs at all". + bool supportsPinExpander() const override { return platform::lcdLanes > 0; } - /// The base pads spare bus lanes with this GPIO. Unrouted lanes cost nothing here, so the value is + /// No async double-buffer on this backend β€” the own-GDMA two-buffer completion handshake races and + /// wedges the bus (see busInit). Single-buffer only; the ring is where MoonI80's speed lives. + bool supportsDoubleBuffer() const override { return false; } + + /// The orchestrator pads spare bus lanes with this GPIO. Unrouted lanes cost nothing here, so the value is /// only ever *used* in shift mode β€” where WR is a real pad and the padding is genuinely inert. - uint16_t clockPinForBus() const { return static_cast(clockPin); } + uint16_t clockPinForBus() const override { return static_cast(clockPin); } /// WR is a '595 pin here, so the control follows the expander toggle: bound always (a saved value /// survives a round-trip through direct mode) but shown only when a shift register can read it. - void addBusControls() { - controls_.addPin("clockPin", clockPin); - controls_.setHidden(controls_.count() - 1, !pinExpanderMode()); + void addBusControls(ControlList& controls) override { + controls.addPin("clockPin", clockPin); + controls.setHidden(controls.count() - 1, !owner_->pinExpanderMode()); } /// The output path + the ring's geometry and instrument. A separate hook from addBusControls() so the - /// base can place these AFTER latchPin β€” clockPin and latchPin are one '595 wiring pair and belong - /// together in the UI, not split by a mode selector. - void addRingControls() { - // The shift-clock speed switch, below the clockPin/latchPin wiring pair (the base places this - // hook right after latchPin): OFF = 20 MHz (safe default), ON = 26.67 MHz (overclock). The fix - // for per-strand '595 corruption is OFF; see the member doc. Shift-mode only. - controls_.addBool("shiftOverclock", shiftOverclock); - controls_.setHidden(controls_.count() - 1, !pinExpanderMode()); - controls_.setAdvanced(controls_.count() - 1); // a '595 clock tuning knob β€” expert only + /// orchestrator can place these AFTER latchPin β€” clockPin and latchPin are one '595 wiring pair and + /// belong together in the UI, not split by a mode selector. + void addRingControls(ControlList& controls) override { + // The shift-clock speed switch, below the clockPin/latchPin wiring pair (the orchestrator places + // this hook right after latchPin): OFF = 20 MHz (safe default), ON = 26.67 MHz (overclock). The + // fix for per-strand '595 corruption is OFF; see the member doc. Shift-mode only. + controls.addBool("shiftOverclock", shiftOverclock); + controls.setHidden(controls.count() - 1, !owner_->pinExpanderMode()); + controls.setAdvanced(controls.count() - 1); // a '595 clock tuning knob β€” expert only // Path selector (pin-expander mode only): the ring, or the whole frame. A distinct axis from // ringSnapshot β€” this picks the PATH, ringSnapshot tunes how the RING reads its source; they // compose. Whole-frame is the A/B reference: it is how the whole-frame-PSRAM-at-the-expander-clock // question stays testable on the same board and content. - controls_.addBool("useRing", useRing); - controls_.setHidden(controls_.count() - 1, !pinExpanderMode()); + controls.addBool("useRing", useRing); + controls.setHidden(controls.count() - 1, !owner_->pinExpanderMode()); // The source-snapshot A/B knob, directly under useRing (the path it belongs to): the ring reads // its source through an immutable snapshot (ON, the safe default) or the live buffer (OFF, a bench // lever). Meaningful only when the ring is the chosen path, so hidden on wantsRing() like the - // geometry below. `ringSnapshot` lives on the base (ParallelLedDriver); the control binds it here. - controls_.addBool("ringSnapshot", ringSnapshot); - controls_.setHidden(controls_.count() - 1, !wantsRing()); + // geometry below. `ringSnapshot` lives on the orchestrator (ParallelLedDriver); the control binds + // it here through the mutable reference accessor. + controls.addBool("ringSnapshot", owner_->ringSnapshotRef()); + controls.setHidden(controls.count() - 1, !wantsRing()); // The geometry + the instrument, shown only when the RING is the chosen path β€” all meaningless on // the whole-frame one. (Gating on wantsRing() is safe: it reads plain members, pinExpanderMode + // useRing, not frameBytes_, so it resolves even before the source buffer is wired at boot.) - controls_.addBool("ringAuto", ringAuto); - controls_.setHidden(controls_.count() - 1, !wantsRing()); + controls.addBool("ringAuto", ringAuto); + controls.setHidden(controls.count() - 1, !wantsRing()); // ringRows/ringBufs/ringPadUs are DEV TUNING β€” ringAuto derives them for the end user (kept // visible above); the manual knobs are expert-only. (Once ringAuto is verified to always pick the // right geometry, ringAuto itself could go advanced too β€” for now it stays visible as the recourse.) - controls_.addUint8("ringRows", ringRows, 1, 64); - controls_.setHidden(controls_.count() - 1, !wantsRing()); - controls_.setAdvanced(controls_.count() - 1); - controls_.addUint8("ringBufs", ringBufs, platform::kRingBufsMin, platform::kRingBufsMax); - controls_.setHidden(controls_.count() - 1, !wantsRing()); - controls_.setAdvanced(controls_.count() - 1); - controls_.addUint8("ringPadUs", ringPadUs, 0, platform::kRingPadMaxUs); - controls_.setHidden(controls_.count() - 1, !wantsRing()); - controls_.setAdvanced(controls_.count() - 1); + controls.addUint8("ringRows", ringRows, 1, 64); + controls.setHidden(controls.count() - 1, !wantsRing()); + controls.setAdvanced(controls.count() - 1); + controls.addUint8("ringBufs", ringBufs, platform::kRingBufsMin, platform::kRingBufsMax); + controls.setHidden(controls.count() - 1, !wantsRing()); + controls.setAdvanced(controls.count() - 1); + controls.addUint8("ringPadUs", ringPadUs, 0, platform::kRingPadMaxUs); + controls.setHidden(controls.count() - 1, !wantsRing()); + controls.setAdvanced(controls.count() - 1); // Ring internals, so the streaming can be diagnosed by polling /api/state (reliable) rather than // scraping serial. The raw instrument β€” expert only; the full field-by-field legend lives on the // ringDbgStr_ member below (rendered into the technical page). - controls_.addReadOnly("ringDbg", ringDbgStr_, sizeof(ringDbgStr_)); - controls_.setHidden(controls_.count() - 1, !wantsRing()); - controls_.setAdvanced(controls_.count() - 1); + controls.addReadOnly("ringDbg", ringDbgStr_, sizeof(ringDbgStr_)); + controls.setHidden(controls.count() - 1, !wantsRing()); + controls.setAdvanced(controls.count() - 1); } - /// Refresh the ringDbg diagnostic string once a second (base tick1s chains here via refreshBusKpi). - /// Nothing to report on the whole-frame path β€” the control is hidden there, so leave it untouched - /// rather than writing a "no ring" status that only repeats what useRing says. - void refreshBusKpi() { + /// Refresh the ringDbg diagnostic string once a second (the orchestrator's tick1s chains here via + /// refreshBusKpi). Nothing to report on the whole-frame path β€” the control is hidden there, so leave + /// it untouched rather than writing a "no ring" status that only repeats what useRing says. + void refreshBusKpi() override { const platform::MoonI80RingStats s = platform::moonI80Ws2812RingStats(bus_); if (!s.isRing) return; // Read-and-clear the segment sums each 1 s window β€” a free-running uint32 sum wraps every // ~20 s at the ring's accumulation rate, which silently garbles the averages (measured: se "64"). - const uint32_t segGather = dbgSegGatherCy, segEmit = dbgSegEmitCy, segRows = dbgSegRows; - dbgSegGatherCy = 0; dbgSegEmitCy = 0; dbgSegRows = 0; + const uint32_t segGather = ParallelLedDriver::dbgSegGatherCy; + const uint32_t segEmit = ParallelLedDriver::dbgSegEmitCy; + const uint32_t segRows = ParallelLedDriver::dbgSegRows; + ParallelLedDriver::dbgSegGatherCy = 0; + ParallelLedDriver::dbgSegEmitCy = 0; + ParallelLedDriver::dbgSegRows = 0; // The extended fields (ld/tx/ipb/ci/tn) are the LAPPING-phase instruments β€” the same readouts that // isolated the prime-only bugs (ld = drain progress, tx = real wire time vs the physical frame // minimum, ipb/ci/tn = node accounting + the terminator). Their scope lives in the backlog's ring // entry. - // sn/lv: memory residency of the two encode sources β€” snapshotBuf_ (sn) and the live source (lv); - // P = PSRAM, I = internal, '-' = absent. The ISR reads one of these per byte, and a PSRAM 'sn' - // under a lapping ring is the measured 4-8x encode cost + the cache-contention corruption exposure. - const char snapWhere = this->snapshotBuf_ - ? (platform::ptrIsPsram(this->snapshotBuf_) ? 'P' : 'I') : '-'; - const char liveWhere = (this->sourceBuffer_ && this->sourceBuffer_->data()) - ? (platform::ptrIsPsram(this->sourceBuffer_->data()) ? 'P' : 'I') : '-'; + // sn/lv: memory residency of the two encode sources β€” the snapshot buffer (sn) and the live source + // (lv); P = PSRAM, I = internal, '-' = absent. The ISR reads one of these per byte, and a PSRAM + // 'sn' under a lapping ring is the measured 4-8x encode cost + the cache-contention corruption + // exposure. + const uint8_t* snap = owner_->snapshotBuf(); + const Buffer* src = owner_->sourceBuffer(); + const char snapWhere = snap + ? (platform::ptrIsPsram(snap) ? 'P' : 'I') : '-'; + const char liveWhere = (src && src->data()) + ? (platform::ptrIsPsram(src->data()) ? 'P' : 'I') : '-'; std::snprintf(ringDbgStr_, sizeof(ringDbgStr_), "sn%c lv%c sl%u/bf%u co%u cr%u ab%u dn%u ld%u lt%u tx%u ipb%u ci%u tn%d de%u enc%u ea%u sg%u se%u tw%u ts%u tp%u gap%u", snapWhere, liveWhere, static_cast(s.nSlices), static_cast(s.ringBufs), @@ -364,9 +380,9 @@ class MoonLedDriver : public ParallelLedDriver { static_cast(s.maxEncodeUs), static_cast(s.avgEncodeUs), static_cast(segRows ? segGather / segRows : 0), // avg gather cycles/row (last window) static_cast(segRows ? segEmit / segRows : 0), // avg emit cycles/row (last window) - static_cast(ParallelLedDriver::dbgTickWaitUs), // wire-wait Β΅s - static_cast(ParallelLedDriver::dbgTickSnapUs), // snapshot Β΅s - static_cast(ParallelLedDriver::dbgTickPrimeUs), // prime Β΅s + static_cast(ParallelLedDriver::dbgTickWaitUs), // wire-wait Β΅s + static_cast(ParallelLedDriver::dbgTickSnapUs), // snapshot Β΅s + static_cast(ParallelLedDriver::dbgTickPrimeUs), // prime Β΅s static_cast(s.maxIsrGapUs)); // co = lifetime cache-off ISR defers (flash/WiFi write; ~10/s at idle is normal). cr = // worst consecutive-defer run β‰ˆ buffers drained un-refilled in one window. ab = frames @@ -378,10 +394,10 @@ class MoonLedDriver : public ParallelLedDriver { // enc = worst ISR refill-encode Β΅s (producer); gap = worst EOF-to-EOF Β΅s (deadline). // enc >= gap == the refill can't keep pace (PACE); enc << gap but still fails == CURSOR/logic. } - /// Which of this driver's controls need the BUS rebuilt (not just a re-encode) when they change: + /// Which of this backend's controls need the BUS rebuilt (not just a re-encode) when they change: /// the WR pin, the output path, and the ring's geometry β€” buffers are sized and the DMA chain mounted /// at build time, so each of these is a rebuild. - bool busControlTriggersBuild(const char* name) const { + bool busControlTriggersBuild(const char* name) const override { return std::strcmp(name, "clockPin") == 0 || std::strcmp(name, "useRing") == 0 // path switch: rebuild the bus on the new path || std::strcmp(name, "ringAuto") == 0 // re-derive (or stop deriving) the geometry @@ -395,34 +411,58 @@ class MoonLedDriver : public ParallelLedDriver { /// WR only reaches a pad in shift mode, so it can only COLLIDE in shift mode. In direct mode the /// signal never leaves the peripheral, so `clockPin` naming a strand's GPIO is harmless β€” and /// rejecting it would forbid a perfectly good config for the sake of a signal nobody reads. - const char* validateBusFatal() const { - if (pinExpanderMode()) { + const char* validateBusFatal() const override { + if (owner_->pinExpanderMode()) { // The '595 needs WR on a real GPIO (it is the SRCLK). Unset (-1) would route the // peripheral's WR signal to GPIO 65535 β€” reject it before busInit reaches the pad. if (clockPin < 0) return "the 74HCT595 expander needs a clockPin (its shift clock)"; - if (latchPin >= 0 && latchPin == clockPin) + if (owner_->latchPin >= 0 && owner_->latchPin == clockPin) return "latchPin is on clockPin (WR) β€” the latch needs its own GPIO"; } return nullptr; } /// A data lane sharing WR's GPIO is silent corruption β€” the matrix routes both signals to the one /// pad and that strand emits the shift clock instead of pixel data. Only possible in shift mode. - const char* validateBusPins(const uint16_t* lanes, uint8_t n) const { - if (!pinExpanderMode()) return nullptr; + const char* validateBusPins(const uint16_t* lanes, uint8_t n) const override { + if (!owner_->pinExpanderMode()) return nullptr; for (uint8_t i = 0; i < n; i++) if (lanes[i] == static_cast(clockPin)) return "a data pin is on clockPin (WR)"; return nullptr; } /// Create the bus + its DMA buffer(s) for `frameBytes`. `busPinList()`/`busPinCount()` come from - /// the base (in shift mode the list appends the latch β€” it is a bus lane), and + /// the orchestrator (in shift mode the list appends the latch β€” it is a bus lane), and /// `busClockMultiplier()` tells the platform how many bus words one WS2812 slot is shifted out /// over, so it can scale the pixel clock and the slot keeps its wire duration. - bool busInit(size_t frameBytes, bool wantSecondBuffer) { + bool busInit(size_t frameBytes, bool /*wantSecondBuffer*/) override { platform::moonI80SetShiftClockDiv(shiftOverclock ? 3 : 4); // ON = 26.67 MHz, OFF = 20 MHz - return platform::moonI80Ws2812Init(bus_, this->busPinList(), this->busPinCount(), + // Force single-buffer HERE regardless of the request: this backend's own-GDMA whole-frame + // two-buffer completion handshake races and wedges the bus (the double-buffer freeze). The + // orchestrator already gates the request via supportsDoubleBuffer()==false, but the backend + // enforces its own contract too, so a future caller that forgets the gate can't reach the broken + // path. MoonI80's speed comes from the streaming ring, not from double-buffering a whole frame. + return platform::moonI80Ws2812Init(bus_, owner_->busPinList(), owner_->busPinCount(), static_cast(clockPin), frameBytes, - wantSecondBuffer, this->busClockMultiplier()); + /*wantSecondBuffer=*/false, owner_->busClockMultiplier()); + } + void busDeinit() override { stopSnapHelper(); platform::moonI80Ws2812Deinit(bus_); } + uint8_t* busBuffer(uint8_t i) override { return platform::moonI80Ws2812Buffer(bus_, i); } + size_t busCapacity() const override { return platform::moonI80Ws2812BufferCapacity(bus_); } + bool busTransmit(uint8_t i, size_t bytes) override { return platform::moonI80Ws2812Transmit(bus_, i, bytes); } + bool busWait(uint8_t i, uint32_t ms) override { return platform::moonI80Ws2812Wait(bus_, i, ms); } + uint32_t busLastTransmitUs() const override { return platform::moonI80Ws2812LastTransmitUs(bus_); } + + /// Drive `frame` on a private bus and capture the wire back on `loopbackRxPin` (jumpered), so the + /// self-test bit-verifies what the peripheral ACTUALLY emitted β€” the one instrument that does not + /// take the driver's word for it. + platform::RmtLoopbackResult busLoopback(const uint8_t* frame, size_t frameBytes, + size_t dataBytes, uint8_t rowBits) override { + return platform::moonI80Ws2812Loopback(owner_->busPinList(), owner_->busPinCount(), + static_cast(clockPin), + static_cast(owner_->loopbackRxPin), + frame, frameBytes, dataBytes, rowBits, + owner_->busClockMultiplier(), + ringRows, ringBufs, useRing); } /// Should reinit build a RING for this config instead of the whole-frame path? Only in pin-expander @@ -434,17 +474,18 @@ class MoonLedDriver : public ParallelLedDriver { /// itself as a decision, and its silent fallback made the ACTIVE path invisible β€” the driver reported /// "driving N lights" while quietly running whole-frame from PSRAM, which does not clock at the /// expander's 26.67 MHz. The switch says what runs. - bool wantsRing() const { - if (!pinExpanderMode()) return false; // direct mode never rings (drives PSRAM fine) + bool wantsRing() const override { + if (!owner_->pinExpanderMode()) return false; // direct mode never rings (drives PSRAM fine) return useRing; } /// Bring the bus up as a streaming RING (the phase-2 path): the platform loops a few small internal /// buffers and calls back per drained buffer to refill it, so a frame too big for internal RAM never - /// materialises (see platform.h). `rowBytes`/`padBytes` come from the base's frame arithmetic; the - /// trampoline below is the encode seam. Returns false if even the small ring won't fit β€” the base - /// then falls back to busInit (whole-frame), which idles with a status if IT can't fit either. - bool busInitRing(size_t rowBytes, uint32_t totalRows) { + /// materialises (see platform.h). `rowBytes`/`padBytes` come from the orchestrator's frame arithmetic; + /// the trampoline below is the encode seam. Returns false if even the small ring won't fit β€” the + /// orchestrator then falls back to busInit (whole-frame), which idles with a status if IT can't fit + /// either. + bool busInitRing(size_t rowBytes, uint32_t totalRows) override { // AUTO geometry (see ringAuto): derive the winning combination for THIS config and write it into // the visible controls β€” rows = one-node max (fewest slices), bufs = as deep as fits. The RAM // budget takes free internal MINUS a reserve (WiFi/HTTP need their share; same spirit as the @@ -468,10 +509,10 @@ class MoonLedDriver : public ParallelLedDriver { ringBufs = static_cast(bufs >= platform::kRingBufsMin ? bufs : platform::kRingBufsMin); } platform::moonI80SetShiftClockDiv(shiftOverclock ? 3 : 4); // ON = 26.67 MHz, OFF = 20 MHz - const bool ok = platform::moonI80Ws2812InitRing(bus_, this->busPinList(), this->busPinCount(), + const bool ok = platform::moonI80Ws2812InitRing(bus_, owner_->busPinList(), owner_->busPinCount(), static_cast(clockPin), rowBytes, totalRows, - ringRows, ringBufs, ringPadUs, this->busClockMultiplier(), - &MoonLedDriver::ringEncodeTrampoline, this); + ringRows, ringBufs, ringPadUs, owner_->busClockMultiplier(), + &MoonI80Peripheral::ringEncodeTrampoline, this); // Ring up β†’ bring the parallel-snapshot helper up too (idempotent; parks in waitNotify). Torn down // in busDeinit with the bus. Spawned here (cold reinit path) so kick()/join() only ever notify. if (ok) ensureSnapHelper(); @@ -482,7 +523,7 @@ class MoonLedDriver : public ParallelLedDriver { // ring buffers are independent (each derives its rows from its index), so the helper primes the bottom // half of the pool on core 0 while this core primes the top half, the join fences both, then the arm // starts the DMA. Serial fallback: the platform's prime-all-then-arm combo. - bool busTransmitRing() { + bool busTransmitRing() override { if (snapHelperReady() && ringBufs >= 2) { // `done` is written-gated (leads the wire drain); the prime itself takes the wire barrier β€” // each prime call waits out the previous frame's deterministic wire end before writing (see @@ -499,17 +540,17 @@ class MoonLedDriver : public ParallelLedDriver { } /// The ring's regime for the driving-status suffix: "primed" (whole frame encoded before arming β€” /// no deadline, pixel-perfect) vs "lapping" (the ISR refills behind the DMA β€” the deadline regime). - const char* busRingMode() const { + const char* busRingMode() const override { const platform::MoonI80RingStats s = platform::moonI80Ws2812RingStats(bus_); if (!s.isRing) return nullptr; return s.nSlices <= s.ringBufs ? "primed" : "lapping"; } - /// Did the bus actually come up as a ring? The base routes tick() on this, so it reports what the - /// platform BUILT, not what was asked for β€” a ring that would not fit falls back to whole-frame. - bool busIsRing() const { return platform::moonI80Ws2812IsRing(bus_); } + /// Did the bus actually come up as a ring? The orchestrator routes tick() on this, so it reports what + /// the platform BUILT, not what was asked for β€” a ring that would not fit falls back to whole-frame. + bool busIsRing() const override { return platform::moonI80Ws2812IsRing(bus_); } - /// The platform's `MoonI80EncodeFn` seam: a plain function pointer (there is no CRTP hook for it), so - /// this static trampoline recovers `this` from `user` and encodes one slice into the ring buffer the + /// The platform's `MoonI80EncodeFn` seam: a plain function pointer (there is no virtual hook for it), + /// so this static trampoline recovers `this` from `user` and encodes one slice into the ring buffer the /// platform hands it. `dst` is the buffer; `firstRow`/`rowCount` name the slice; `closeFrame` gates /// the trailing latch pad (only the last slice emits it). /// @@ -522,14 +563,15 @@ class MoonLedDriver : public ParallelLedDriver { /// mode writes every slot word in encodeRows, so it needs no prefill. static void MM_RAMFUNC ringEncodeTrampoline(void* user, uint8_t* dst, uint32_t firstRow, uint32_t rowCount, bool closeFrame, bool needsPrefill) { - auto* self = static_cast(user); - const uint8_t outCh = self->correction_.outChannels; + auto* self = static_cast(user); + ParallelLedDriver* owner = self->owner_; + const uint8_t outCh = owner->correction().outChannels; const auto first = static_cast(firstRow); const auto count = static_cast(rowCount); if (rowCount == 0) { // The FRAME-CLOSE call (see MoonI80EncodeFn): write only the latch-only word, which presents // the register's final slot on the strand. Direct mode has no close word β€” zeros are LOW. - if (closeFrame) self->encodeFrameClose(dst); + if (closeFrame) owner->encodeFrameClose(dst); return; } // Prefill only when the buffer's constants are actually gone (`needsPrefill` β€” the platform's @@ -538,54 +580,23 @@ class MoonLedDriver : public ParallelLedDriver { // ~1/3 of the ISR encode cost β€” the difference between the refill fitting its drain deadline or not. // RAGGED strands still prefill every time: the active mask varies per ROW, so a buffer holding a // different slice needs that slice's row masks re-laid regardless of recycling. - const bool prefill = self->pinExpanderMode() && (needsPrefill || !self->uniformLaneCounts()); - if (self->slotBytes() == 1) { - if (prefill) self->prefillShiftRows(outCh, dst, first, count); - self->encodeRows(outCh, dst, first, count, closeFrame); + const bool prefill = owner->pinExpanderMode() && (needsPrefill || !owner->uniformLaneCounts()); + if (owner->slotBytes() == 1) { + if (prefill) owner->prefillShiftRows(outCh, dst, first, count); + owner->encodeRows(outCh, dst, first, count, closeFrame); } else { - if (prefill) self->prefillShiftRows(outCh, dst, first, count); - self->encodeRows(outCh, dst, first, count, closeFrame); + if (prefill) owner->prefillShiftRows(outCh, dst, first, count); + owner->encodeRows(outCh, dst, first, count, closeFrame); } } - /// The whole-frame path's DMA buffer `i` (0, or 1 with doubleBuffer on) β€” where the base encodes a - /// frame. Null on a ring handle, which has no whole-frame buffer to hand out. - uint8_t* busBuffer(uint8_t i) { return platform::moonI80Ws2812Buffer(bus_, i); } - /// Bytes that buffer holds β€” the base's guard against encoding past the end after a live resize. - size_t busCapacity() const { return platform::moonI80Ws2812BufferCapacity(bus_); } - /// Clock buffer `i` out: one gapless DMA transfer of `bytes`, returning as soon as it is armed. - bool busTransmit(uint8_t i, size_t bytes) { return platform::moonI80Ws2812Transmit(bus_, i, bytes); } - /// Block until buffer `i` has finished clocking (or `ms` elapses) β€” how the async double-buffer - /// defers its wait to the NEXT frame instead of stalling this one. - bool busWait(uint8_t i, uint32_t ms) { return platform::moonI80Ws2812Wait(bus_, i, ms); } - /// Measured wire time of the last frame, in Β΅s β€” the `frameTime` KPI's source, and the output floor - /// the encode is compared against. - uint32_t busLastTransmitUs() const { return platform::moonI80Ws2812LastTransmitUs(bus_); } - /// Tear the bus down: stop the DMA before freeing anything it could still read. Safe on a - /// half-built bus, so a failed init and a live one release through the same path. The snapshot helper - /// task goes down FIRST β€” it reads snapshotBuf_, which the base frees on release, so no wake may land - /// after; stopPinnedTask joins, so once it returns the helper is provably gone. - void busDeinit() { stopSnapHelper(); platform::moonI80Ws2812Deinit(bus_); } - - /// Drive `frame` on a private bus and capture the wire back on `loopbackRxPin` (jumpered), so the - /// self-test bit-verifies what the peripheral ACTUALLY emitted β€” the one instrument that does not - /// take the driver's word for it. - platform::RmtLoopbackResult busLoopback(const uint8_t* frame, size_t frameBytes, - size_t dataBytes, uint8_t rowBits) { - return platform::moonI80Ws2812Loopback(this->busPinList(), this->busPinCount(), - static_cast(clockPin), - static_cast(loopbackRxPin), - frame, frameBytes, dataBytes, rowBits, - this->busClockMultiplier(), - ringRows, ringBufs, useRing); - } /// WR is part of the bus identity, so a change to it rebuilds the bus β€” not just a data-pin edit. - void recordBusPins() { lastClockPin_ = clockPin; } - /// Do this driver's extra bus pins still match the live bus? WR is bus identity here, so the base - /// rebuilds when this goes false rather than routing a stale clock. - bool extraBusPinsCurrent() const { return lastClockPin_ == clockPin; } + void recordBusPins() override { lastClockPin_ = clockPin; } + /// Do this backend's extra bus pins still match the live bus? WR is bus identity here, so the + /// orchestrator rebuilds when this goes false rather than routing a stale clock. + bool extraBusPinsCurrent() const override { return lastClockPin_ == clockPin; } - // --- Fork-join helper (CRTP override of the base's default no-op hook) --- + // --- Fork-join helper (the ring's core-0 prime-half worker) --- // The pool PRIME (~14 ms at 48Γ—256) is embarrassingly parallel β€” each ring buffer derives its rows from // its own index β€” so under the render/encode split (ring tick on core 1) one core-0 helper task primes // the bottom half of the pool while core 1 primes the top, per frame. The task is spawned once at ring @@ -600,7 +611,7 @@ class MoonLedDriver : public ParallelLedDriver { /// β€” i.e. the render/encode split is active and core 0 is the idle one to hand the bottom half. On /// core 0 (single-core, or the split disengaged), there is no idle second core, so stay serial and /// avoid spawning contention onto the very core doing the render. - bool snapHelperReady() const { + bool snapHelperReady() const override { return snapHelper_.impl != nullptr && !snapHelperBroken_ && platform::currentCore() == 1; } @@ -659,7 +670,7 @@ class MoonLedDriver : public ParallelLedDriver { // very first kick doesn't wait on a park signal the helper only emits after it starts running. snapHelperParked_.store(true, std::memory_order_release); snapHelperDone_.store(true, std::memory_order_release); - platform::spawnPinnedTask(snapHelper_, "mmSnap", &MoonLedDriver::snapHelperTramp, this, + platform::spawnPinnedTask(snapHelper_, "mmSnap", &MoonI80Peripheral::snapHelperTramp, this, 4096, 5, /*core=*/0); } void stopSnapHelper() { @@ -669,7 +680,7 @@ class MoonLedDriver : public ParallelLedDriver { } static void snapHelperTramp(void* user) { - auto* self = static_cast(user); + auto* self = static_cast(user); while (!self->snapHelperStop_.load(std::memory_order_acquire)) { // Announce PARKED before blocking, so helperKick knows the previous job is fully done and the // helper is no longer reading helperJob_/the bounds β€” only then does it publish the next job. @@ -700,4 +711,10 @@ class MoonLedDriver : public ParallelLedDriver { int8_t lastClockPin_ = -1; }; +// Register the MoonI80 (own-GDMA below esp_lcd) backend into the peripheral registry once, at +// static-init. Gated by this header's CONFIG_SOC include in main.cpp (LCD_CAM chips only). No separate +// driver class β€” the one ParallelLedDriver drives it, chosen via the `peripheral` control. +inline const bool kMoonI80PeripheralRegistered = + ParallelLedDriver::registerPeripheral("MoonI80", []() -> LedPeripheral* { return new MoonI80Peripheral(); }); + } // namespace mm diff --git a/src/light/drivers/MultiPinLedDriver.h b/src/light/drivers/MultiPinLedDriver.h index d0c3193b..45e8c99e 100644 --- a/src/light/drivers/MultiPinLedDriver.h +++ b/src/light/drivers/MultiPinLedDriver.h @@ -1,6 +1,6 @@ #pragma once -#include "light/drivers/ParallelLedDriver.h" // shared CRTP body +#include "light/drivers/ParallelLedDriver.h" // shared driver body + LedPeripheral #include "platform/platform.h" @@ -10,16 +10,17 @@ namespace mm { /// β€” the parallel scale path on **all three i80-capable ESP32 families**. RMT gives a chip 4-8 /// channels; this gives 8-16 lanes for the wall time of one. The magic is that ESP-IDF exposes ONE /// public i80 API (`esp_i80_new_i80_bus` / `esp_i80_panel_io_tx_color`) and routes it to whichever -/// peripheral the silicon has β€” so this single driver serves every chip: -/// - **ESP32-S3 / -P4:** backed by the dedicated **LCD_CAM** peripheral. +/// peripheral the silicon has β€” so this single backend serves every chip: +/// - **ESP32-S3 / -P4 / -S31:** backed by the dedicated **LCD_CAM** peripheral. /// - **classic ESP32:** backed by the **I2S peripheral in i80/LCD mode** (the classic has no LCD_CAM; /// I2S-i80 is its only >8-lane route). IDF's own CMake picks the backend by chip; the two are /// mutually exclusive per silicon, so `lanesAvailable()` reads whichever lane-count constant is /// non-zero. Named for the **i80 bus** (the shared API), not a peripheral, since it isn't one -/// peripheral β€” same reason its siblings are named `Rmt`/`Parlio` (their APIs). +/// peripheral β€” same reason its sibling backend is named `Parlio` (its own API). /// /// The shared body (slicing, the whole-frame async double-buffer DMA, the fused encode, the loopback -/// self-test, the `frameTime` KPI) lives in ParallelLedDriver; this class adds only the i80-specific pieces: +/// self-test, the `frameTime` KPI) lives in ParallelLedDriver; this backend adds only the i80-specific +/// pieces: /// - The sacrificial WR (pixel clock) + DC GPIOs the i80 bus mandates even though WS2812 ignores /// both, and the "exactly 8 or 16 pins" rule (the i80 layer rejects a partial bus). A sub-16 board /// parks unused lanes + WR/DC on one spare GPIO (the ghost-pin trick). @@ -29,7 +30,7 @@ namespace mm { /// ~416 ns: newer WS2812B revisions spec T0H max β‰ˆ 380 ns, and a longer `0` on a direct 3.3 V line /// gets misread as `1` (the strip washes white). One bus word per slot (bus bit L = the L-th pin); /// unequal strands idle LOW once exhausted. Slot layout: ParallelSlots.h. -/// - Both backends do **whole-frame chained DMA** (autonomous, CPU out of the timing loop), so the +/// - Both silicon paths do **whole-frame chained DMA** (autonomous, CPU out of the timing loop), so the /// classic I2S path is WiFi-underrun-immune by construction β€” it does NOT need the ISR-refilled /// ring / large `nbDmaBuffer` cushion the raw-register I2S-clockless lineage requires. /// - The platform::i80Ws2812* calls (ESP-IDF's esp_lcd i80 bus + GDMA). @@ -37,7 +38,7 @@ namespace mm { /// Prior art: Adafruit's LCD_CAM discovery, hpwit's I2SClockless lineage (classic-ESP32 I2S parallel), /// FastLED's S3 driver β€” architecture studied, never copied. We build on IDF's maintained esp_lcd i80 /// abstraction rather than tracing the raw-register I2S driver (*Industry standards, our own code*). -class MultiPinLedDriver : public ParallelLedDriver { +class I80Peripheral : public LedPeripheral { public: // Data pins + loopback pin default to UNSET: they are user-soldered (the strand // runs to whatever GPIOs the user wired), so a hard-coded default would be a @@ -45,7 +46,7 @@ class MultiPinLedDriver : public ParallelLedDriver { // the driver idles meanwhile (the "default only when it cannot do harm" rule; // see lessons.md). The ESP32-S3 N16R8 Dev bench wiring is pins "1,2,4,5,6,7,8,9", // loopbackRxPin 12 (kept clear of the octal-PSRAM pins 26-37, USB 19/20, and - // strapping pins) β€” set those again to reproduce the bench. (Base declares + // strapping pins) β€” set those again to reproduce the bench. (The orchestrator declares // pins="" and loopbackRxPin=0, so the empty default needs no code here.) /// WR (pixel clock) and DC: the IDF i80 bus *requires* both on real GPIOs @@ -53,8 +54,8 @@ class MultiPinLedDriver : public ParallelLedDriver { /// ignore both β€” they are peripheral-fixed, not user-strand wiring, so a sensible overridable /// default cannot do harm (same class as the chip-fixed Ethernet pins). The data pins gate /// startup, so the bus stays idle until the user sets them regardless. (Dropping WR/DC entirely - /// needs a direct-LCD_CAM driver that bypasses esp_lcd, hpwit-style β€” backlogged, not this - /// increment.) + /// needs a direct-LCD_CAM backend that bypasses esp_lcd, hpwit-style β€” that is MoonI80Peripheral, + /// backlogged as this increment's sibling.) /// /// **`clockPin` is ONE pin doing TWO jobs, and with a 74HCT595 expander the second one is /// load-bearing.** WR toggles once per bus word in hardware β€” which is exactly what a '595's @@ -79,80 +80,116 @@ class MultiPinLedDriver : public ParallelLedDriver { /// ROM, not an API contract: the S3 and classic ROMs open with an unsigned bounds compare and return /// without writing (a silent no-op), but the **ESP32-P4 ROM has no such guard** and computes a store /// ~0x50120554 β€” a quarter-megabyte past the GPIO block, in another peripheral's window β€” plus a - /// >31-bit shift. This driver runs on the P4. IDF's own `esp_rom/patches/esp_rom_gpio.c` is unguarded + /// >31-bit shift. This backend runs on the P4. IDF's own `esp_rom/patches/esp_rom_gpio.c` is unguarded /// too, so the S3's check is an implementation detail a patch could remove, not a promise. FastLED's /// LCD_CAM driver parks both pins on a dummy GPIO for the same reason. To spend no GPIO at all, use - /// MoonLedDriver: owning the DMA below esp_lcd, it holds DC at a constant level and routes WR only + /// MoonI80Peripheral: owning the DMA below esp_lcd, it holds DC at a constant level and routes WR only /// when a '595 needs it as SRCLK. int8_t clockPin = 10; int8_t dcPin = 11; - // --- CRTP hooks the base calls (all non-virtual; no vtable) --- + // --- LedPeripheral descriptors --- - /// The number of i80 lanes this chip provides (0 = no i80 bus on this chip); the base's + /// The number of i80 lanes this chip provides (0 = no i80 bus on this chip); the orchestrator's /// inert-on-wrong-chip guards key off it. Reads whichever backend the silicon has β€” - /// `lcdLanes` (LCD_CAM, S3/P4) or `i2sLanes` (I2S-i80, classic ESP32) β€” which are mutually + /// `lcdLanes` (LCD_CAM, S3/P4/S31) or `i2sLanes` (I2S-i80, classic ESP32) β€” which are mutually /// exclusive per chip (at most one is non-zero), so the sum picks the right one. - static constexpr uint8_t lanesAvailable() { return platform::lcdLanes + platform::i2sLanes; } - static constexpr bool kPowerOfTwoBus = true; // the BUS rounds to 8/16; the pin count is free + uint8_t lanesAvailable() const override { return platform::lcdLanes + platform::i2sLanes; } + bool powerOfTwoBus() const override { return true; } // the BUS rounds to 8/16; the pin count is free + + /// Whole-frame DMA byte budget. On the classic ESP32 the i80 is the I2S peripheral: its DMA is + /// INTERNAL-RAM only (no PSRAM) and it holds the whole frame (no streaming ring), so a frame larger + /// than the free internal DMA block simply cannot allocate β€” and the failing esp_lcd path can busy- + /// wait to a watchdog reset. reinit() pre-checks against this and idles with a clear status instead. + /// Budget = the largest free internal block minus a fixed reserve for the bus descriptors + other + /// allocations that land between this query and the alloc; sized for ONE frame, since busInit() + /// downgrades the optional second (doubleBuffer) buffer on its own when only one fits. The classic + /// path is bounded by construction, so it never returns 0 (which means "no bound"): when the block + /// is at or under the reserve, it reports a small positive floor so the fit gate still rejects. + /// On the LCD_CAM chips (S3/P4/S31) the DMA reaches PSRAM β†’ 0 = no bound (the interface default). COLD PATH. + size_t dmaBudgetBytes() const override { + if constexpr (platform::i2sLanes > 0) { + const size_t block = platform::maxInternalAllocBlock(); + constexpr size_t kReserve = 16 * 1024; // descriptors + headroom for allocs after this query + constexpr size_t kMinBudget = 1; // never 0 on the bounded path (0 == "no bound") + return block > kReserve ? block - kReserve : kMinBudget; + } else { + return 0; // LCD_CAM (S3/P4/S31): PSRAM DMA, no whole-frame ceiling + } + } + // The i80 loopback can't build a 1-lane private bus, so it rebuilds the FULL-WIDTH bus and // carries the pattern on lane 0 β€” the loopback frame must be encoded at the operational bus - // width (16-bit for a 16-lane driver) to match. (Parlio can do a 1-lane unit, so it sets false.) - static constexpr bool kLoopbackFullWidth = true; - static constexpr const char* kInitFailMsg = "i80 bus init failed β€” check pins / memory"; + // width (16-bit for a 16-lane driver) to match. (Parlio can do a 1-lane unit, so its backend + // sets false.) + bool loopbackFullWidth() const override { return true; } + /// The classic ESP32's esp_lcd-i80 backend IS the I2S peripheral; on the LCD_CAM chips (S3/P4/S31) it is LcdCam (shared + /// with MoonI80, which is why the two conflict). Keys on the same i2sLanes flag lanesAvailable does. + LedHwBlock hwBlock() const override { + if constexpr (platform::i2sLanes > 0) return LedHwBlock::I2s; + else return LedHwBlock::LcdCam; + } + const char* initFailMsg() const override { return "i80 bus init failed β€” check pins / memory"; } /// Spare bus lanes (shift mode, when the board has fewer data pins than the bus is wide) park on /// WR: the peripheral already drives it and the board already wires it, so the lane is inert. - /// (Hides the base default; this is the "ghost pin" the platform layer uses for the same reason.) - uint16_t clockPinForBus() const { return static_cast(clockPin); } + /// (Overrides the interface default; this is the "ghost pin" the platform layer uses for the same + /// reason.) + uint16_t clockPinForBus() const override { return static_cast(clockPin); } /// Bind the i80-specific bus controls: the sacrificial WR (clockPin) and DC pins /// the peripheral mandates. - void addBusControls() { - controls_.addPin("clockPin", clockPin); - controls_.addPin("dcPin", dcPin); + void addBusControls(ControlList& controls) override { + controls.addPin("clockPin", clockPin); + controls.addPin("dcPin", dcPin); } /// A clockPin or dcPin change triggers a bus rebuild via the prepare sweep. - bool busControlTriggersBuild(const char* name) const { + bool busControlTriggersBuild(const char* name) const override { return std::strcmp(name, "clockPin") == 0 || std::strcmp(name, "dcPin") == 0; } - /// Reject a data lane that collides with the WR (clockPin) or DC pin. The i80 - /// peripheral routes a distinct output signal to each of the 8 data lanes plus - /// WR + DC via the GPIO matrix; IDF does NOT check that they differ, so a data - /// pin equal to clockPin/dcPin gets two signals on one GPIO and that lane emits - /// the clock/DC waveform instead of pixel data (silent corruption β€” the strip on - /// that lane shows garbage). Fail loud + idle instead, same shape as the other - /// parse errors. (Hides the base's no-op default; the base calls this via CRTP.) - // Returns a WARNING string (not an error) if a data lane sits on clockPin (WR) or - // dcPin: that lane emits the bus-control waveform instead of pixel data. It's a - // warning because on a board that wires all 8/16 lanes but drives fewer strands, - // parking WR/DC on an unused data pin is a valid choice β€” only a lane driving a - // real strand shows garbage. The base routes this to setConfigWarn; the driver - // keeps running. null when the WR/DC pins are clear of the data set. /// FATAL bus-pin check β†’ routed to the ERROR path (idles the driver), unlike validateBusPins' /// per-lane WARNINGS. WR and DC on the SAME GPIO breaks the i80 bus outright (it needs two /// distinct control lines β€” the bus won't init), so it can't be a warn-and-run like a data-lane - /// collision (which only corrupts that one lane). null = no fatal condition. (CRTP hook; the - /// base's default returns null, Parlio has no WR/DC and keeps the default.) - const char* validateBusFatal() const { - if (clockPin >= 0 && clockPin == dcPin) + /// collision (which only corrupts that one lane). null = no fatal condition. + const char* validateBusFatal() const override { + // An UNSET clockPin/dcPin (-1) is fatal on i80: the bus mandates a valid WR and DC GPIO, but + // clockPinForBus()/busInit cast the int8_t to uint16_t, so -1 becomes 65535 and slips past + // IDF's own `wr_gpio_num >= 0 && dc_gpio_num >= 0` guard (see the clockPin doc above). Reject it + // here, before that cast, so an unconfigured board idles with a clear status instead of an + // init on a garbage GPIO number. + if (clockPin < 0) return "clockPin (WR) is unset β€” the i80 bus needs a valid WR GPIO"; + if (dcPin < 0) return "dcPin is unset β€” the i80 bus needs a valid DC GPIO"; + if (clockPin == dcPin) return "clockPin (WR) and dcPin are the same GPIO β€” they must differ"; // The '595 latch is a BUS LANE, so it needs its own GPIO: sharing it with WR would make the // pixel clock double as the latch (the '595 would present a byte on every shift cycle), and // sharing it with DC would latch on the command phase. Both are fatal β€” the bus builds, but // the strands get garbage β€” so this is an error, not a warning. (Bench-found: WR defaults to // GPIO 10, which is the first pin a user reaches for when picking a latch.) - if (pinExpanderMode() && latchPin >= 0) { - if (latchPin == clockPin) + if (owner_->pinExpanderMode() && owner_->latchPin >= 0) { + if (owner_->latchPin == clockPin) return "latchPin is on clockPin (WR) β€” the latch needs its own GPIO"; - if (latchPin == dcPin) + if (owner_->latchPin == dcPin) return "latchPin is on dcPin β€” the latch needs its own GPIO"; } return nullptr; } - const char* validateBusPins(const uint16_t* lanes, uint8_t n) const { + /// Reject a data lane that collides with the WR (clockPin) or DC pin. The i80 + /// peripheral routes a distinct output signal to each of the 8 data lanes plus + /// WR + DC via the GPIO matrix; IDF does NOT check that they differ, so a data + /// pin equal to clockPin/dcPin gets two signals on one GPIO and that lane emits + /// the clock/DC waveform instead of pixel data (silent corruption β€” the strip on + /// that lane shows garbage). Fail loud + idle instead, same shape as the other + /// parse errors. + // Returns a WARNING string (not an error) if a data lane sits on clockPin (WR) or + // dcPin: that lane emits the bus-control waveform instead of pixel data. It's a + // warning because on a board that wires all 8/16 lanes but drives fewer strands, + // parking WR/DC on an unused data pin is a valid choice β€” only a lane driving a + // real strand shows garbage. The orchestrator routes this to setConfigWarn, the driver + // keeps running. null when the WR/DC pins are clear of the data set. + const char* validateBusPins(const uint16_t* lanes, uint8_t n) const override { for (uint8_t i = 0; i < n; i++) { // clockPin/dcPin are int8_t (-1 = unset); only a real GPIO can collide. if (clockPin >= 0 && lanes[i] == static_cast(clockPin)) @@ -166,62 +203,62 @@ class MultiPinLedDriver : public ParallelLedDriver { /// Create the i80 bus + its DMA buffer(s) sized for `frameBytes` on the current data lanes plus /// the WR/DC pins; `wantSecondBuffer` requests the async double-buffer's second frame buffer /// (allocated only if it fits β€” else single-buffer). Returns whether init succeeded. - /// **LCD_CAM** is the one backend that can host the 74HCT595 expander: it reaches PSRAM (so the + /// **LCD_CAM** is the one silicon path that can host the 74HCT595 expander: it reaches PSRAM (so the /// Γ—8 frame fits), it has no single-transfer cap, and its WR pixel-clock pin IS the shift clock a - /// '595 needs. The classic ESP32 shares this class but not that backend β€” its i80 is the I2S + /// '595 needs. The classic ESP32 shares this backend but not that silicon path β€” its i80 is the I2S /// peripheral, whose DMA cannot read PSRAM at all, so a 154 KB frame has nowhere to live. Keying - /// the flag on `lcdLanes` (non-zero only on the LCD_CAM chips, S3/P4) makes the refusal a - /// compile-time property of the silicon rather than a runtime surprise, and the base then reports - /// it as a config error instead of letting the bus die at init with "check pins / memory". - static constexpr bool kSupportsPinExpander = platform::lcdLanes > 0; + /// the flag on `lcdLanes` (non-zero only on the LCD_CAM chips, S3/P4/S31) makes the refusal a + /// compile-time property of the silicon rather than a runtime surprise, and the orchestrator then + /// reports it as a config error instead of letting the bus die at init with "check pins / memory". + bool supportsPinExpander() const override { return platform::lcdLanes > 0; } - /// The bus pin list comes from the base: in shift mode it appends the latch to the data pins + /// The bus pin list comes from the orchestrator: in shift mode it appends the latch to the data pins /// (the latch is a bus lane), so the peripheral drives it. busClockMultiplier() tells the platform /// how many bus words one WS2812 slot is shifted out over, so it can scale the pixel clock and the /// slot keeps its wire duration. - bool busInit(size_t frameBytes, bool wantSecondBuffer) { - return platform::i80Ws2812Init(i80_, this->busPinList(), this->busPinCount(), + bool busInit(size_t frameBytes, bool wantSecondBuffer) override { + return platform::i80Ws2812Init(i80_, owner_->busPinList(), owner_->busPinCount(), static_cast(clockPin), static_cast(dcPin), frameBytes, wantSecondBuffer, - this->busClockMultiplier()); + owner_->busClockMultiplier()); } - /// DMA buffer `i` (0/1) the base encodes into; buffer 1 is null when the second + /// DMA buffer `i` (0/1) the orchestrator encodes into; buffer 1 is null when the second /// buffer didn't fit (single-buffer mode). Both are the same size (busCapacity). - uint8_t* busBuffer(uint8_t i) { return platform::i80Ws2812Buffer(i80_, i); } + uint8_t* busBuffer(uint8_t i) override { return platform::i80Ws2812Buffer(i80_, i); } /// The per-buffer byte capacity (fixed at bus creation; both buffers equal). - size_t busCapacity() const { return platform::i80Ws2812BufferCapacity(i80_); } + size_t busCapacity() const override { return platform::i80Ws2812BufferCapacity(i80_); } /// Kick off the autonomous transfer of the first `bytes` of DMA buffer `i`; /// returns whether it started. - bool busTransmit(uint8_t i, size_t bytes) { return platform::i80Ws2812Transmit(i80_, i, bytes); } + bool busTransmit(uint8_t i, size_t bytes) override { return platform::i80Ws2812Transmit(i80_, i, bytes); } /// Block up to `ms` for buffer `i`'s in-flight transfer to complete. - bool busWait(uint8_t i, uint32_t ms) { return platform::i80Ws2812Wait(i80_, i, ms); } + bool busWait(uint8_t i, uint32_t ms) override { return platform::i80Ws2812Wait(i80_, i, ms); } /// The most recent DMA transfer's wire time (Β΅s) β€” the WS2812 output floor. - uint32_t busLastTransmitUs() const { return platform::i80Ws2812LastTransmitUs(i80_); } + uint32_t busLastTransmitUs() const override { return platform::i80Ws2812LastTransmitUs(i80_); } /// Tear down the i80 bus and its DMA buffer. - void busDeinit() { platform::i80Ws2812Deinit(i80_); } + void busDeinit() override { platform::i80Ws2812Deinit(i80_); } /// Run the loopback self-test. The i80 layer requires all 8 data GPIOs valid, so /// a 1-lane private bus is impossible; the loopback builds the full-width bus and /// carries the pattern on lane 0. Passes the WR/DC pins the init needs. platform::RmtLoopbackResult busLoopback(const uint8_t* frame, size_t frameBytes, - size_t dataBytes, uint8_t rowBits) { - // The private bus is built from the base's bus pin list (which appends the latch in shift - // mode β€” the latch is a bus lane) and at the shift-mode pclk, so the test transmits exactly - // what the render loop does. In direct mode both reduce to today's behaviour. - return platform::i80Ws2812Loopback(this->busPinList(), this->busPinCount(), + size_t dataBytes, uint8_t rowBits) override { + // The private bus is built from the orchestrator's bus pin list (which appends the latch in + // shift mode β€” the latch is a bus lane) and at the shift-mode pclk, so the test transmits + // exactly what the render loop does. In direct mode both reduce to today's behavior. + return platform::i80Ws2812Loopback(owner_->busPinList(), owner_->busPinCount(), static_cast(clockPin), static_cast(dcPin), - static_cast(loopbackRxPin), + static_cast(owner_->loopbackRxPin), frame, frameBytes, dataBytes, rowBits, - this->busClockMultiplier()); + owner_->busClockMultiplier()); } /// Store WR/DC alongside the data pins, so a clockPin/dcPin edit rebuilds the /// bus too (not just a data-pin change). - void recordBusPins() { lastClockPin_ = clockPin; lastDcPin_ = dcPin; } + void recordBusPins() override { lastClockPin_ = clockPin; lastDcPin_ = dcPin; } /// Whether the live bus's WR/DC pins still match the current clockPin/dcPin (so - /// the base can skip a rebuild). - bool extraBusPinsCurrent() const { + /// the orchestrator can skip a rebuild). + bool extraBusPinsCurrent() const override { return lastClockPin_ == clockPin && lastDcPin_ == dcPin; } @@ -231,4 +268,11 @@ class MultiPinLedDriver : public ParallelLedDriver { int8_t lastDcPin_ = -1; }; +// Register the esp_lcd-i80 backend into the ParallelLedDriver peripheral registry once, at static-init. +// The label is what the `peripheral` Select shows. Gated by this header's own CONFIG_SOC include in +// main.cpp, so it only registers on i80-capable silicon. There is no separate driver class: the one +// registered ParallelLedDriver drives every backend, selected by the `peripheral` control. +inline const bool kI80PeripheralRegistered = + ParallelLedDriver::registerPeripheral("i80", []() -> LedPeripheral* { return new I80Peripheral(); }); + } // namespace mm diff --git a/src/light/drivers/ParallelLedDriver.h b/src/light/drivers/ParallelLedDriver.h index a2d7d91b..314667ec 100644 --- a/src/light/drivers/ParallelLedDriver.h +++ b/src/light/drivers/ParallelLedDriver.h @@ -1,6 +1,7 @@ #pragma once #include "light/drivers/DriverBase.h" // DriverBase, Correction +#include "light/drivers/LedPeripheral.h" // runtime peripheral strategy (Parlio / i80 / MoonI80) #include "light/drivers/ParallelSlots.h" // encodeWs2812ParallelSlots (shared encoder) #include "light/drivers/LedDriverConfig.h" #include "light/drivers/PinList.h" // parsePinList / assignCounts (shared) @@ -10,10 +11,10 @@ namespace mm { -template -/// Base for the parallel WS2812B LED-output drivers β€” the S3's LCD_CAM i80 bus (MultiPinLedDriver) and -/// the P4's Parlio peripheral (ParlioLedDriver). Both drive up to 16 strands that clock out -/// SIMULTANEOUSLY, one GPIO lane each, fed consecutive slices of the source buffer (see kMaxLanes). +/// The one registered parallel WS2812B LED-output driver. It drives up to 16 strands that clock out +/// SIMULTANEOUSLY, one GPIO lane each, fed consecutive slices of the source buffer (see kMaxLanes), over +/// whichever bus peripheral the `peripheral` control selects at runtime (the S3's LCD_CAM i80 bus, its +/// own-GDMA MoonI80 shift-ring, or the P4's Parlio peripheral β€” see the LedPeripheral strategy below). /// (Vocabulary β€” strand / lane / slot / row β€” under @xref{terminology|More info β†’ Terminology}.) /// /// **How it works: encode the whole frame, then hand it to the DMA and walk away.** Every WS2812 bit of @@ -32,14 +33,17 @@ template /// by `ledsPerPin` with the remainder split evenly β€” the same rule (and the same parser) as /// RmtLedDriver, so a strand count means the same thing on every LED driver. /// -/// **CRTP, not virtual calls.** The base calls into the derived through -/// `static_cast(this)->busX()`, so the shared body costs no vtable and no runtime indirection -/// on the hot path, and the module tree stays the project's one deliberate class hierarchy (the only -/// virtual boundary is MoonModule -> DriverBase). A derived driver supplies only the peripheral-specific -/// pieces: the `bus*` platform wrappers, `lanesAvailable()` (which makes it inert on the wrong chip), -/// `kPowerOfTwoBus` (the i80 bus rounds to 8 or 16; Parlio's width IS its pin count), the slot rate -/// `kClockHz`, and any extra bus pins it owns. The two drivers were ~250 of ~370 lines byte-for-byte -/// identical before this base existed. +/// **A runtime `LedPeripheral` strategy, not compile-time CRTP.** The base calls into `peripheral_` +/// (a `LedPeripheral*`, see LedPeripheral.h) through one virtual-dispatch boundary per FRAME or per +/// reinit β€” never per light, so the per-light encode (the actual hot path) stays a direct call and pays +/// no vtable cost. A peripheral supplies only the variant-specific pieces: the `bus*` platform wrappers, +/// `lanesAvailable()` (which makes it inert on the wrong chip), `powerOfTwoBus()` (the i80 bus rounds to +/// 8 or 16; Parlio's width IS its pin count), and any extra bus pins it owns. Each backend header +/// (I80Peripheral, MoonI80Peripheral, ParlioPeripheral) self-registers its factory + label with the +/// peripheral registry at static-init; which backends link is `#if`-gated per chip's CONFIG_SOC_* at the +/// backend `#include`s in main.cpp, so a board links only its usable backends and the `peripheral` +/// control offers that subset. A fresh driver wires the first usable backend so it is +/// functional out of the box; the control (or a catalog `peripheral` value) switches it live. /// /// @moreinfo /// @@ -58,9 +62,61 @@ template /// and an i80 bus word have the same meaning. class ParallelLedDriver : public DriverBase { public: - /// WS2812/SK6812 strips are GRB-wired, so a fresh parallel LED driver (and its MultiPinLedDriver - /// subclass) references the "GRB" preset by default. The user can pick any preset. - ParallelLedDriver() { this->setDefaultPresetName("GRB"); } + /// Test-only: swap in a peripheral backend for a test mock. Deinits + drops any existing peripheral + /// first (a rebuild-from-scratch, same as a real reinit would need). The caller retains ownership β€” + /// this borrows the pointer, exactly like a registered driver's own owned backend does not need a + /// test to manage its lifetime. + void setPeripheralForTest(LedPeripheral* p) { + if (peripheral_ && peripheralOwned_) { peripheral_->busDeinit(); delete peripheral_; } + else if (peripheral_) peripheral_->busDeinit(); + peripheral_ = p; + peripheralOwned_ = false; // the test owns a stack/heap mock; the orchestrator must not delete it + peripheralActiveReg_ = 0xFF; + if (p) p->attach(this); + } + + // --- Peripheral backend registry --- + // The set of peripheral backends compiled into THIS build. Each backend header self-registers its + // factory + label at static-init, via an `inline const bool kXxxPeripheralRegistered = + // registerPeripheral(...)` at the bottom of the backend header (e.g. MoonLedDriver.h, ParlioLedDriver.h). + // WHICH backends link is decided in main.cpp: the `#include` of each backend header is `#if`-gated on + // the chip's CONFIG_SOC_* β€” so a classic ESP32 only links the esp_lcd-i80 backend, an S3 links i80 + + // MoonI80, a P4 links all three. The `peripheral` Select then offers the linked-and-supported subset + // at runtime. Mirrors ModuleFactory's static-registration shape, one level down for the bus backend. + using PeripheralFactory = LedPeripheral* (*)(); + struct PeripheralEntry { const char* label; PeripheralFactory make; }; + static constexpr uint8_t kMaxPeripherals = 4; + static inline PeripheralEntry peripheralRegistry_[kMaxPeripherals] = {}; + static inline uint8_t peripheralRegistryCount_ = 0; + /// Register a backend factory under a UI label. Called once per linked backend at static-init. + /// Duplicate labels and overflow past kMaxPeripherals are ignored (the backstop is far above the + /// three real backends). + static bool registerPeripheral(const char* label, PeripheralFactory make) { + if (!label || !make || peripheralRegistryCount_ >= kMaxPeripherals) return false; + for (uint8_t i = 0; i < peripheralRegistryCount_; i++) + if (std::strcmp(peripheralRegistry_[i].label, label) == 0) return false; + peripheralRegistry_[peripheralRegistryCount_++] = {label, make}; + return true; + } + + /// WS2812/SK6812 strips are GRB-wired, so a fresh parallel LED driver references the "GRB" preset by + /// default (the user can pick any). A fresh driver also wires the first peripheral this chip supports + /// so it is functional out of the box; the `peripheral` control (or a catalog `peripheral` value) + /// switches it. On desktop no backend is linked, so peripheral_ stays null and the driver idles β€” + /// tests inject a mock via setPeripheralForTest. + ParallelLedDriver() { + this->setDefaultPresetName("GRB"); + selectDefaultPeripheral(nullptr); + } + + /// Free the owned backend. A registered driver's backend (created via the registry) is owned and + /// deleted here; a test-borrowed mock (setPeripheralForTest) is not β€” the ownership flag decides, + /// so the delete lives ONCE in the base rather than per thin subclass. The owner released()/removed + /// this driver before destruction (the DriverBase vptr-race contract), so the bus is already down. + ~ParallelLedDriver() override { + if (peripheral_ && peripheralOwned_) delete peripheral_; + peripheral_ = nullptr; + } /// Max parallel lanes = the peripheral's full 16 data lines. The bus width the driver /// actually builds is DERIVED from the configured pin count: ≀8 pins β†’ an 8-bit bus (uint8 @@ -252,20 +308,30 @@ class ParallelLedDriver : public DriverBase { /// Is the shift-register expander engaged? (Reads the control; the alias keeps the intent /// readable at the call sites that ask "am I encoding through a register?") - bool pinExpanderMode() const { return pinExpander; } + // The EFFECTIVE expander mode: the user's saved `pinExpander` AND a peripheral that can host the + // '595. Gating here (not by mutating `pinExpander`) means a peripheral that can't host it degrades to + // direct mode at runtime while the saved value survives β€” so switching back to a supporting + // peripheral restores shift mode. Every consumer (lane math, latch, ring, encode) reads this. + bool pinExpanderMode() const { return pinExpander && peripheral_ && peripheral_->supportsPinExpander(); } /// Strands driven per physical data pin: 1 direct, or the '595's width through the expander. - /// The multiplier every size/clock/slot computation below is expressed in. - uint8_t outputsPerPin() const { return pinExpander ? kPinExpanderOutputs : uint8_t(1); } - - /// Bind the driver's controls: the window (start/count), the `pins` and - /// `ledsPerPin` text lists, any derived-supplied bus controls (i80 adds - /// clockPin/dcPin, Parlio none), and the loopback self-test controls (TX/RX pin - /// overrides always bound but shown only in test mode). + /// The multiplier every size/clock/slot computation below is expressed in. Reads the EFFECTIVE mode + /// (pinExpanderMode), so on a peripheral that can't host the '595 the lane math is direct even though + /// the saved `pinExpander` value is kept β€” matching every other consumer. + uint8_t outputsPerPin() const { return pinExpanderMode() ? kPinExpanderOutputs : uint8_t(1); } + + /// Bind the driver's controls: the peripheral-INVARIANT block first (window, `pins`, `ledsPerPin`, + /// `frameTime` β€” they describe *which LEDs and how many*, the same on any backend), THEN the + /// `peripheral` selector as a divider, THEN the peripheral-SPECIFIC controls the chosen backend + /// surfaces (`doubleBuffer` where supported, i80's clockPin/dcPin, the `pinExpander` toggle, MoonI80's + /// ring cluster). The loopback cluster is last (expert). This ordering makes the card read top-down: + /// everything above `peripheral` is invariant; everything below is what that peripheral supports. void defineDriverControls() override { addWindowControls(); // start / count β€” the slice of the shared buffer this driver outputs controls_.addText("pins", pins, sizeof(pins)); controls_.addText("ledsPerPin", ledsPerPin, sizeof(ledsPerPin)); - controls_.addBool("doubleBuffer", doubleBuffer); // double-buffer on/off (latency opt-out) + // `doubleBuffer` is peripheral-DEPENDENT (a peripheral that can't run the async path hides it β€” + // MoonI80), so it lives BELOW the peripheral divider with the other peripheral-specific controls, + // not up here in the invariant block. Added after the swap; see below. // ringSnapshot is added in the derived driver's addRingControls() (below the useRing path selector, // with the rest of the ring cluster) β€” it is a ring-only knob, so it belongs with the ring // controls, not up here with the path-neutral ones. @@ -273,21 +339,44 @@ class ParallelLedDriver : public DriverBase { // floor (independent of render load), so it shows how much headroom remains as the pipeline // improves β€” and it reflects an overclocked slot rate directly. Refreshed in tick1s(). controls_.addReadOnly("frameTime", frameTimeStr_, sizeof(frameTimeStr_)); - // A checkbox: the expander is fitted or it isn't. The '595's width (8) is the chip's, not a - // setting, so there is nothing to type β€” and a boolean can't be half-configured. + // The peripheral selector β€” the divider between the invariant block above and the + // peripheral-specific controls below. buildPeripheralOptions syncs peripheralSel_ to the live + // backend (or clamps it); when a peripheral CHANGE triggered this rebuild, peripheralSel_ already + // holds the NEW index (applyControlValue wrote it before rebuildControls), so + // ensurePeripheralMatchesSelection() makes the live object agree BEFORE addBusControls/addRingControls + // surface the chosen backend's controls. Always shown, even with a single option: with one backend + // it reads as a labeled indicator of what's driving the LEDs (e.g. "i80" on the classic). + buildPeripheralOptions(); + ensurePeripheralMatchesSelection(); + controls_.addSelect("peripheral", peripheralSel_, peripheralOptions_, peripheralOptionCount_); + // doubleBuffer β€” the first peripheral-specific control, directly under the divider. Peripheral- + // dependent: a peripheral that can't run the async path hides it (MoonI80 β€” single-buffer only, + // its own-GDMA two-buffer handshake races; see supportsDoubleBuffer / its busInit). Added AFTER + // ensurePeripheralMatchesSelection so a peripheral CHANGE evaluates the visibility against the NEW + // backend. The saved value stays bound, so it survives a round-trip through a single-buffer-only + // peripheral and re-engages when a peripheral that supports it is selected again. + controls_.addBool("doubleBuffer", doubleBuffer); + controls_.setHidden(controls_.count() - 1, !(peripheral_ && peripheral_->supportsDoubleBuffer())); + // The bus pins sit UNDER the peripheral selector because they ARE peripheral-specific: for a driver + // that owns its own GPIO routing they are '595 pins: MoonI80 routes WR only when a shift register + // needs it as SRCLK, and hides the control otherwise. (I80 goes through esp_lcd, which mandates a + // valid WR *and* DC GPIO whatever the mode, so it shows both unconditionally β€” same hook, different answer.) + if (peripheral_) peripheral_->addBusControls(controls_); + // The 74HCT595 pin expander toggle + its latch pin. It comes BEFORE the ring cluster (below) + // because that cluster DEPENDS on it β€” the ring/shift controls are shown only when the expander + // is on, so the switch that controls them must sit above them (the dependent-below-controlling + // rule). Peripheral-specific β€” shown only when the SELECTED peripheral can host the '595 (i80 / + // MoonI80 on LCD_CAM; not Parlio, whose single-shot transfer can't carry the Γ—8 fan-out frame β€” + // a hardware limit, see supportsPinExpander). The '595's width (8) is the chip's, not a setting, + // so the toggle is a plain checkbox; latchPin is bound always (a saved value survives a + // round-trip through direct mode) but shown only when the expander is on. controls_.addBool("pinExpander", pinExpander); - // The bus pins sit UNDER the expander toggle because for a driver that owns its own GPIO - // routing they are '595 pins: MoonI80 routes WR only when a shift register needs it as SRCLK, - // and hides the control otherwise. (I80 goes through esp_lcd, which mandates a valid WR *and* - // DC GPIO whatever the mode, so it shows both unconditionally β€” same hook, different answer.) - derived()->addBusControls(); - // Always bound, shown only when the expander is on β€” the conditional-control shape - // (bound regardless so a saved latchPin survives a round-trip through direct mode). + controls_.setHidden(controls_.count() - 1, !(peripheral_ && peripheral_->supportsPinExpander())); controls_.addPin("latchPin", latchPin); controls_.setHidden(controls_.count() - 1, !pinExpanderMode()); - // A ring-capable backend's geometry, AFTER latchPin so the bus pins stay one unbroken group - // (clockPin and latchPin are a wiring pair). Default no-op; only MoonI80 rings. - derived()->addRingControls(); + // The ring cluster (MoonI80 only; default no-op) β€” shift-mode geometry, all gated on the + // expander being on, so it sits UNDER the pinExpander switch that governs it. + if (peripheral_) peripheral_->addRingControls(controls_); // The on-device loopback self-test + its wiring β€” a dev/bring-up instrument (jumper a lane to the // rx pin), not a casual-user setting, so the whole cluster is expert-only. controls_.addBool("loopbackTest", loopbackTest); @@ -325,7 +414,7 @@ class ParallelLedDriver : public DriverBase { || std::strcmp(name, "doubleBuffer") == 0 || std::strcmp(name, "pinExpander") == 0 || std::strcmp(name, "latchPin") == 0 || isWindowControl(name) - || derived()->busControlTriggersBuild(name); // clockPin/dcPin on i80 + || (peripheral_ && peripheral_->busControlTriggersBuild(name)); // clockPin/dcPin on i80 } /// React to a control change off the render loop. loopbackTest is a persistent @@ -333,6 +422,21 @@ class ParallelLedDriver : public DriverBase { /// lane-config refresh first, since onControlChanged precedes the prepare sweep); /// turning it OFF clears the verdict and re-derives the real driver status. void onControlChanged(const char* name) override { + // Peripheral swap: bring the newly-selected backend's bus up. Scheduler::setControl already ran + // rebuildControls() BEFORE this, and defineDriverControls calls ensurePeripheralMatchesSelection() + // off peripheralSel_ (the just-written index) β€” so the live backend was ALREADY swapped during the + // rebuild and the control list is bound to it. We must NOT swap again here: a second swapPeripheral + // would free that backend and build a third, leaving every backend-owned control (clockPin, ring + // cluster, ...) dangling into freed memory. ensurePeripheralMatchesSelection() is a no-op now + // (peripheralActiveReg_ already equals the wanted reg); we only re-parse against the new backend's + // descriptors (powerOfTwoBus differs Parlio vs i80) and reinit to bring the bus up. + if (std::strcmp(name, "peripheral") == 0) { + ensurePeripheralMatchesSelection(); + parseConfig(); + reinit(); + DriverBase::onControlChanged(name); + return; + } const bool isTestControl = std::strcmp(name, "loopbackTest") == 0; // Every control that reshapes the lane config the self-test transmits through. pinExpander // and latchPin belong here for the same reason `pins` does: they change laneList_, @@ -446,7 +550,7 @@ class ParallelLedDriver : public DriverBase { /// lifted the P4 whole-board rate 48β†’76 fps; OFF is the sound-reactive 0-latency opt-out and pays /// for exactly one buffer β€” see the doubleBuffer control + docs/history/lessons.md.) void tick() override { - if constexpr (Derived::lanesAvailable() == 0) return; // inert off this chip + if (!peripheral_ || peripheral_->lanesAvailable() == 0) return; // no backend / inert off this chip // Loopback mode owns the peripheral EXCLUSIVELY. While it is on, the render loop must not // transmit β€” the loopback tears the bus down, drives its own private frame, and rebuilds it. if (loopbackTest) return; @@ -462,15 +566,15 @@ class ParallelLedDriver : public DriverBase { // small internal buffers refilled by the platform, so it does NOT hold the whole frame and the // busCapacity() guard below (which the other two need) does not apply. The whole-frame paths keep // their guard and their proven behavior byte-for-byte. - if (derived()->busIsRing()) { tickRing(outCh); return; } - if (frameBytes_ > derived()->busCapacity()) return; + if (peripheral_->busIsRing()) { tickRing(outCh); return; } + if (frameBytes_ > peripheral_->busCapacity()) return; // Two explicitly-separate whole-frame paths so the OFF path is PROVABLY the pre-Step-1.5 behavior // (no regression) and pays nothing for the double-buffer it isn't using. The mode is fixed by // whether the second buffer was allocated (busInit(async) β€” ON allocs it, OFF doesn't), so a // stale flag can't route a single-buffer bus down the async path. - if (derived()->busBuffer(1)) tickAsync(outCh); // double-buffer (doubleBuffer ON) - else tickSync(outCh); // synchronous (doubleBuffer OFF / no 2nd buf) + if (peripheral_->busBuffer(1)) tickAsync(outCh); // double-buffer (doubleBuffer ON) + else tickSync(outCh); // synchronous (doubleBuffer OFF / no 2nd buf) } // Synchronous single-buffer path β€” the ORIGINAL tick, verbatim: encode buffer 0, transmit, wait @@ -483,7 +587,7 @@ class ParallelLedDriver : public DriverBase { // A previous frame's wait may have timed out, leaving the DMA still reading buffer 0 β€” re-wait // rather than encoding over a live transfer. (Normally a no-op: the wait below completes.) if (!busWaitIfBusy(0)) return; - uint8_t* buf = derived()->busBuffer(0); + uint8_t* buf = peripheral_->busBuffer(0); if (!buf) return; // Branch on the BUS WIDTH (slotBytes), not the strand count β€” with a '595 expander the // strands ride the shift cycles, so 48 strands on 6 pins is still an 8-bit bus. @@ -493,7 +597,7 @@ class ParallelLedDriver : public DriverBase { // LOW >=300 Β΅s. Wait only when the transfer started β€” a failed transmit gives no done-callback, // so an unconditional wait would block the full timeout. A timed-out wait leaves the buffer // marked in-flight so the next tick re-waits instead of corrupting the live transfer. - if (derived()->busTransmit(0, frameBytes_)) { + if (peripheral_->busTransmit(0, frameBytes_)) { inFlight_[0] = true; busWaitIfBusy(0); // synchronous: wait it out here; clears the flag on completion } @@ -515,14 +619,14 @@ class ParallelLedDriver : public DriverBase { // instead of emitting garbage, and it self-heals the moment the transfer completes. if (!busWaitIfBusy(active_)) return; // 2. Fused per-ROW encode into buffer `active_`, one branch on the bus width (see encodeRows). - uint8_t* buf = derived()->busBuffer(active_); + uint8_t* buf = peripheral_->busBuffer(active_); if (!buf) return; // Branch on the BUS WIDTH (slotBytes), not the strand count β€” with a '595 expander the // strands ride the shift cycles, so 48 strands on 6 pins is still an 8-bit bus. if (slotBytes() == 1) encodeRows(outCh, buf); else encodeRows(outCh, buf); // 3. Kick this buffer's DMA and return WITHOUT waiting; flip to the other buffer for next tick. - if (derived()->busTransmit(active_, frameBytes_)) { + if (peripheral_->busTransmit(active_, frameBytes_)) { inFlight_[active_] = true; active_ ^= 1; } @@ -565,7 +669,7 @@ class ParallelLedDriver : public DriverBase { if (ringSnapshot) { if (!snapshotSourceForRing()) return; } else encodeSrc_ = nullptr; // OFF: encodeRows reads the live sourceBuffer_ const uint32_t tkW2 = platform::cycleCount(); - if (derived()->busTransmitRing()) { + if (peripheral_->busTransmitRing()) { inFlight_[0] = true; // kicked; DO NOT wait here β€” the next tick waits, freeing the core now } const uint32_t tkW3 = platform::cycleCount(); @@ -586,17 +690,14 @@ class ParallelLedDriver : public DriverBase { /// the render loop can never beat it, so it's the target the multicore work drives the system tick /// toward, and it tracks an overclocked slot rate directly. "β€”" until the first transfer completes. void tick1s() override { - const uint32_t us = derived()->busLastTransmitUs(); + if (!peripheral_) return; + const uint32_t us = peripheral_->busLastTransmitUs(); if (us == 0) std::snprintf(frameTimeStr_, sizeof(frameTimeStr_), "β€”"); else std::snprintf(frameTimeStr_, sizeof(frameTimeStr_), "%u Β΅s (%u fps max)", static_cast(us), static_cast(1000000u / us)); - derived()->refreshBusKpi(); // per-backend extra read-only KPIs (MoonI80's ring diagnostic); base no-op + peripheral_->refreshBusKpi(); // per-backend extra read-only KPIs (MoonI80's ring diagnostic); base no-op } - /// CRTP hook: refresh any backend-specific read-only KPI once a second (off the hot path). Base is a - /// no-op; MoonLedDriver overrides it to publish its ring diagnostic counters (see ringDbg). - void refreshBusKpi() {} - // Wait for buffer `i`'s in-flight transfer to finish. Returns TRUE when the buffer is free to // reuse β€” either nothing was outstanding (first tick, or a transmit that failed to start, so an // unconditional wait can't block the full timeout) or the transfer completed. Returns FALSE when @@ -608,7 +709,7 @@ class ParallelLedDriver : public DriverBase { /// so callers arm it unconditionally instead of tracking state themselves. bool busWaitIfBusy(uint8_t i) { if (!inFlight_[i]) return true; - if (!derived()->busWait(i, waitBudgetMs())) { + if (!peripheral_ || !peripheral_->busWait(i, waitBudgetMs())) { // The transfer did not complete within many times its own wire time, so it is not going // to. Count it: a bus that keeps doing this is broken, and the ONLY thing that matters // then is that the driver stops spending the render thread on it (see deadFrames_). @@ -720,11 +821,11 @@ class ParallelLedDriver : public DriverBase { /// already carries data or is a zero the buffer's memset provides). Called from reinit(), the cold /// path, whenever the buffers are (re)established or re-zeroed. void prefillShiftConstantsIfNeeded() { - if (!pinExpanderMode() || !inited_) return; + if (!peripheral_ || !pinExpanderMode() || !inited_) return; const uint8_t outCh = correction_.outChannels; if (outCh == 0 || maxLaneLights_ == 0) return; for (uint8_t i = 0; i < 2; i++) { - uint8_t* buf = derived()->busBuffer(i); + uint8_t* buf = peripheral_->busBuffer(i); if (!buf) continue; // buffer 1 is null in single-buffer mode if (slotBytes() == 1) prefillShiftFrame(outCh, buf); else prefillShiftFrame(outCh, buf); @@ -931,6 +1032,11 @@ class ParallelLedDriver : public DriverBase { /// Test-only accessors β€” pin the lane slicing and frame-size arithmetic on the /// host (unit_{I80,Parlio}LedDriver.cpp); the hardware half is proven on device. uint8_t laneCount() const { return laneCount_; } + /// Which DMA buffer this tick encodes into (0/1) β€” the deferred-wait double-buffer's alternation + /// state. Test-only. + uint8_t activeForTest() const { return active_; } + /// Is buffer `i`'s DMA transfer outstanding (awaiting its wait)? Test-only. + bool inFlightForTest(uint8_t i) const { return inFlight_[i]; } /// Lights on lane `i` (0 if out of range). Test-only. nrOfLightsType laneLightCount(uint8_t i) const { return i < laneCount_ ? laneCounts_[i] : 0; } /// All POPULATED strands the same length? Gates the ring's prefill-skip: what the skip actually @@ -966,9 +1072,172 @@ class ParallelLedDriver : public DriverBase { /// Total DMA frame size in bytes (rows + latch pad). Test-only. size_t frameBytes() const { return frameBytes_; } + // --- Peripheral-facing accessors: a backend reaches the orchestrator's parsed lane list, latch + // bit, correction, and loopback pin through these (the back-pointer's read side β€” see owner_ in + // LedPeripheral.h). Replaces what CRTP inheritance gave a derived class for free. + /// Bus-bit index of the latch line (shift mode only) β€” a backend's addRingControls/encode seam + /// reads this through the owner pointer (e.g. masking the latch bit out of a bit-verify). + uint8_t latchBit() const { return latchBit_; } + /// The parsed physical data GPIOs (bus-width bound) β€” a backend's encode trampoline reads lane + /// state through the owner pointer instead of inheriting the array directly. + const uint16_t* laneList() const { return laneList_; } + /// The live output Correction (channel count, role wiring) β€” a backend's encode/prefill helpers + /// read `outChannels` through this instead of inheriting `correction_` directly. + const Correction& correction() const { return correction_; } + /// Mutable reference to the ring-snapshot A/B knob, for a backend's addRingControls to bind + /// (`controls.addBool("ringSnapshot", owner_->ringSnapshotRef())`) β€” addBool binds by reference, + /// so the control needs the member's address, not a copy. + bool& ringSnapshotRef() { return ringSnapshot; } + /// The ring snapshot buffer (null when unallocated / off the ring path) β€” a backend's KPI refresh + /// (MoonLedDriver::refreshBusKpi) reads this to report the buffer's memory residency (PSRAM vs + /// internal) through the owner pointer. + const uint8_t* snapshotBuf() const { return snapshotBuf_; } + /// The wired source buffer (null before setSourceBuffer) β€” same KPI-residency use as snapshotBuf(). + const Buffer* sourceBuffer() const { return sourceBuffer_; } + +protected: + // Fill peripheralOptions_/peripheralIndex_ from the registry, keeping only backends this chip can + // run (lanesAvailable() > 0), and reconcile peripheralSel_ so it still points at the SAME backend + // after the filter (or clamps into range). Built each defineDriverControls, so the Select always + // offers exactly what the board supports. A backend is created briefly to read its lanesAvailable() + // (a cheap object β€” no bus is brought up until busInit), then discarded. + void buildPeripheralOptions() { + // Remember which backend is selected now (by label) so we can re-find it after filtering. + const char* current = (peripheralSel_ < peripheralOptionCount_) ? peripheralOptions_[peripheralSel_] + : nullptr; + peripheralOptionCount_ = 0; + for (uint8_t i = 0; i < peripheralRegistryCount_ && peripheralOptionCount_ < kMaxPeripherals; i++) { + LedPeripheral* probe = peripheralRegistry_[i].make(); + const bool usable = probe && probe->lanesAvailable() > 0; + delete probe; + if (!usable) continue; + peripheralOptions_[peripheralOptionCount_] = peripheralRegistry_[i].label; + peripheralIndex_[peripheralOptionCount_] = i; + peripheralOptionCount_++; + } + if (peripheralOptionCount_ == 0) { // no usable backend on this chip (desktop) β€” a single inert row + peripheralOptions_[0] = "(none)"; + peripheralIndex_[0] = 0; + peripheralOptionCount_ = 1; + } + // Re-point the Select at the same backend it named before (labels are stable), else clamp. + uint8_t sel = 0; + if (current) + for (uint8_t k = 0; k < peripheralOptionCount_; k++) + if (std::strcmp(peripheralOptions_[k], current) == 0) { sel = k; break; } + peripheralSel_ = sel; + } + + // Tear down the current backend and build the one at filtered option slot `k`. Frees the old + // backend's bus + heap (only the selected peripheral costs memory β€” the product-owner requirement). + // Guarded so a bad index or an empty registry leaves peripheral_ null (tick() then idles). + void swapPeripheral(uint8_t k) { + // Stop the core-1 encode worker before freeing the backend. deinit() drains the BUS DMA, but the + // encode worker (owned by Drivers) ticks this driver and dereferences peripheral_ for + // busBuffer/busTransmit/busWait β€” so a live `peripheral` swap (POST /api/control) that reaches + // here while the render split is active would `delete peripheral_` out from under the worker + // mid-tick: a use-after-free, the same class the structural-mutation quiesce fixes. The swap is + // not a child-array mutation, so it does not pass through MoonModule::quiesceForMutation; reach + // the same render-worker hook directly. The trailing reinit()/tick re-engages the split. + MoonModule::notifyQuiesceRender(); + deinit(); // stop any in-flight transfer on the old bus first + if (peripheral_) { + peripheral_->busDeinit(); + if (peripheralOwned_) delete peripheral_; // never delete a test-borrowed mock + peripheral_ = nullptr; + } + peripheralActiveReg_ = 0xFF; + peripheralOwned_ = false; + if (k >= peripheralOptionCount_) return; + const uint8_t reg = peripheralIndex_[k]; + if (reg >= peripheralRegistryCount_) return; + peripheral_ = peripheralRegistry_[reg].make(); + if (peripheral_) { peripheral_->attach(this); peripheralActiveReg_ = reg; peripheralOwned_ = true; } + } + + // Seed the default peripheral for a registered driver (called from its constructor with the backend's + // registry label). Builds the filtered options, points peripheralSel_ at `label` if this chip + // supports it (else the first usable backend β€” a board that names an unavailable default still gets a + // working driver), and swaps the live backend to match. From here the base's selector owns the rest. + void selectDefaultPeripheral(const char* label) { + buildPeripheralOptions(); + peripheralSel_ = 0; // fallback: the first usable backend (a board naming an absent default + // still gets a working driver, and `label == nullptr` means "first"). + if (label) + for (uint8_t k = 0; k < peripheralOptionCount_; k++) + if (std::strcmp(peripheralOptions_[k], label) == 0) { peripheralSel_ = k; break; } + swapPeripheral(peripheralSel_); + } + + // Make the live backend match peripheralSel_. When a `peripheral` control change triggered a + // control rebuild, peripheralSel_ already holds the new index but peripheral_ is still the old + // backend β€” this swaps it so the schema below surfaces the right controls. A no-op when they + // already agree (the common rebuild-for-another-reason case), so it's cheap on every rebuild. + void ensurePeripheralMatchesSelection() { + // A test-borrowed mock (setPeripheralForTest β†’ !peripheralOwned_) is deliberate β€” never swap it + // out from under the test for a registry backend. + if (peripheral_ && !peripheralOwned_) return; + const uint8_t wantReg = (peripheralSel_ < peripheralOptionCount_) ? peripheralIndex_[peripheralSel_] + : 0xFF; + if (peripheral_ && peripheralActiveReg_ == wantReg) return; // already correct + swapPeripheral(peripheralSel_); + } + + // The peripheral-block claim guard: is a SIBLING driver under the same Drivers container already + // driving the hardware block this driver's peripheral wants? The chip has one of each block (one + // LcdCam, one Parlio, one I2S), so two live drivers on the same block corrupt each other. Returns + // true when the block is already claimed. RTTI-free (ESP32 builds -fno-rtti): siblings are compared + // through the virtual `hwBlock()` on every module β€” a non-parallel driver (RMT, NetworkSend) and any + // other module return None, so no cast is needed. Walks the parent's children; skips self + disabled. + bool siblingClaimsBlock() const { + if (!peripheral_) return false; + const LedHwBlock mine = peripheral_->hwBlock(); + if (mine == LedHwBlock::None) return false; + const MoonModule* p = parent(); + if (!p) return false; + for (uint8_t i = 0; i < p->childCount(); i++) { + const MoonModule* sib = p->child(i); + if (sib == this || !sib || !sib->enabled()) continue; + // Every child of Drivers is a DriverBase (role Driver), so this static_cast is safe once + // role() confirms it β€” no RTTI. A non-parallel driver returns None from hwBlock(). + if (sib->role() != ModuleRole::Driver) continue; + if (static_cast(sib)->hwBlock() == mine) return true; + } + return false; + } + +public: + /// The hardware peripheral block this driver is DRIVING, for the sibling claim guard (overrides + /// DriverBase). Gated on inited_: a driver reports its block only while it actually holds the bus, + /// not merely from having a backend selected. Without this a driver that was itself refused (or has + /// no pins yet) would still "claim" the block, so the next prepare sweep β€” re-running a working + /// sibling's reinit() for an unrelated reason (a grid resize, a pin edit) β€” would see the phantom + /// claim and dark the live wall too. The claim must mean "the bus is up", which is exactly inited_. + /// siblingClaimsBlock() derives its OWN block from peripheral_ directly, so gating here only affects + /// how OTHER drivers see this one β€” precisely the intent. + LedHwBlock hwBlock() const override { + return (inited_ && peripheral_) ? peripheral_->hwBlock() : LedHwBlock::None; + } + protected: - Derived* derived() { return static_cast(this); } - const Derived* derived() const { return static_cast(this); } + + + /// The runtime backend. Null only before a registered driver's constructor wires its default + /// backend (or after a test detaches one) β€” every method that dereferences it guards first. + LedPeripheral* peripheral_ = nullptr; + + // The `peripheral` Select's state. peripheralSel_ indexes into the BOARD-FILTERED option list + // (only backends with lanesAvailable() > 0 on this chip), whose labels are held in peripheralOptions_ + // β€” a stable member array because addSelect borrows the pointer (same pattern as presetOptions_). + // peripheralIndex_[k] maps filtered slot k back to its registry index, so a Select change can create + // the right backend. All three are (re)built by buildPeripheralOptions(). + uint8_t peripheralSel_ = 0; + const char* peripheralOptions_[kMaxPeripherals] = {}; + uint8_t peripheralIndex_[kMaxPeripherals] = {}; + uint8_t peripheralOptionCount_ = 0; + uint8_t peripheralActiveReg_ = 0xFF; // registry index the LIVE peripheral_ came from (0xFF = none) + bool peripheralOwned_ = false; // does the orchestrator own peripheral_ (delete it) β€” false + // for a test-borrowed mock (setPeripheralForTest) Buffer* sourceBuffer_ = nullptr; @@ -1178,51 +1447,14 @@ class ParallelLedDriver : public DriverBase { // can keep the same frameBytes_ yet needs // a rebuild, so the fast path checks it too - static constexpr uint8_t maxLanesForTarget() { - return (Derived::lanesAvailable() > 0 && Derived::lanesAvailable() < kMaxLanes) - ? Derived::lanesAvailable() - : kMaxLanes; + /// The pin-list parser's cap: the peripheral's own lane count when it's narrower than kMaxLanes + /// (Parlio may report fewer), else the full kMaxLanes. Falls back to kMaxLanes with no peripheral + /// attached yet (parseConfig then has nothing to parse anyway). + uint8_t maxLanesForTarget() const { + const uint8_t avail = peripheral_ ? peripheral_->lanesAvailable() : 0; + return (avail > 0 && avail < kMaxLanes) ? avail : kMaxLanes; } - /// CRTP hook (default: no extra bus pins to validate). A derived driver whose - /// peripheral commits its own GPIOs beyond the data lanes (the i80 bus's WR/DC) - /// HIDES this to flag a data lane that overlaps them. Returns a WARNING string - /// (the driver keeps running β€” see MultiPinLedDriver::validateBusPins for why it's a - /// warning, not a blocker) or null when the data pins are clean. Parlio has no - /// such pins, so it uses this default. - const char* validateBusPins(const uint16_t* /*lanes*/, uint8_t /*n*/) const { return nullptr; } - - /// FATAL bus-pin check β†’ the ERROR path (idles the driver), for a bus-pin misconfig the peripheral - /// can't init at all (MultiPinLedDriver's clockPin==dcPin). Distinct from validateBusPins' per-lane - /// warnings. Default null; a peripheral with bus control pins overrides it. Parlio has none. - const char* validateBusFatal() const { return nullptr; } - - /// CRTP ring hooks (default: this peripheral has no streaming ring, so it never rings). Only MoonI80 - /// β€” which owns its DMA β€” overrides these to stream a frame too big for internal RAM (see its - /// busInitRing / the tickRing path). The esp_lcd I80 (the memory-capped reference) and Parlio use - /// these defaults: busIsRing() is always false, so tick() never selects tickRing and the ring transmit - /// is never called. reinit() consults wantsRing() to decide whether to attempt a ring build at all. - bool wantsRing() const { return false; } // should reinit try the ring for this config? - void addRingControls() {} // a ring-capable backend's geometry controls - bool busInitRing(size_t /*rowBytes*/, uint32_t /*totalRows*/) { return false; } - bool busIsRing() const { return false; } - bool busTransmitRing() { return false; } - /// The ring's regime as a one-word status suffix ("primed" / "lapping"), or null when not ringing β€” - /// so the driving-status line shows which side of the streaming boundary a config sits on. - const char* busRingMode() const { return nullptr; } - /// Core-0 helper hook (default: no helper). Only the ring driver overrides it, spawning a core-0 - /// worker that primes half the ring pool while core 1 primes the other half (busTransmitRing's - /// fork-join). ready() is true only when the render/encode split is engaged AND the helper task is up. - /// (The snapshot copy is always serial β€” forking it saturated core 0; see snapshotSourceForRing.) - bool snapHelperReady() const { return false; } - - /// CRTP hook: the GPIO a SPARE bus lane is parked on when the pin list is narrower than the bus - /// width (shift mode β€” the board decides the data-pin count, the peripheral decides the width). - /// The i80 driver hides this to return its WR pin, which the peripheral already drives; a - /// peripheral that accepts NC lanes (Parlio) never calls it. Default: lane 0's pin, so a spare - /// lane is at worst a harmless duplicate of a pin the bus already owns. - uint16_t clockPinForBus() const { return laneList_[0]; } - // Frame bytes: longest lane Γ— channels Γ— 24 slots, plus a zeroed latch pad of // >=300 Β΅s at the slot rate (800 slots) with clock-tolerance slack (64), each // scaled by `slotBytes` (1 for an 8-bit bus, 2 for a 16-bit bus β€” a slot is one @@ -1258,11 +1490,14 @@ class ParallelLedDriver : public DriverBase { return (bytes + 63) & ~static_cast(63); } - // Bytes per bus slot: 1 for the 8-bit bus, 2 for the 16-bit bus. Keys on the PHYSICAL - // pin count, not laneCount_ β€” with a '595 expander the lanes ride the shift cycles, not - // extra bus bits, so 48 lanes on 6 pins is still an 8-bit bus. (In direct mode the two - // counts are equal, so this is the original behaviour.) - uint8_t slotBytes() const { return busWidthPins() > 8 ? 2 : 1; } + // Whether a whole-frame DMA buffer of `frameBytes` fits the internal-DMA budget of a driver whose + // DMA can't reach PSRAM (the classic i80's I2S peripheral) and holds the whole frame (no ring). + // A driver that IS so bounded calls this in reinit() (before busInit); if false it idles with a clear + // status instead of choking the bus init on an allocation the peripheral can never satisfy. `budgetBytes` + // 0 means "no bound" (PSRAM-capable / ring drivers) β†’ always fits. + static bool frameFitsDmaBudget(size_t frameBytes, size_t budgetBytes) { + return budgetBytes == 0 || frameBytes <= budgetBytes; + } // The i80 bus width is a POWER OF TWO (8 or 16) β€” a peripheral fact, independent of how many // strands the board drives. In direct mode the pin list already fills it exactly. In shift mode @@ -1290,12 +1525,18 @@ class ParallelLedDriver : public DriverBase { // the peripheral a short array while busPinCount() claims the full width β€” a read past the end. // Kept in the base (one owner of the latch + the padding), so both derived busInit()s just pass // these two calls. +public: + /// Public: a backend (a separate LedPeripheral, reached through the owner_ back-pointer) builds its + /// bus from this + busPinCount()/busClockMultiplier(), same as busPinList()/busPinCount()/slotBytes() + /// below β€” the bus-geometry accessors a backend needs are public, everything else in this block stays + /// protected (this driver's own cold-path config machinery). const uint16_t* busPinList() { const uint8_t width = busWidthPins(); + const uint16_t clockPin = peripheral_ ? peripheral_->clockPinForBus() : laneList_[0]; for (uint8_t i = 0; i < width && i < kMaxLanes; i++) { if (i < physPins_) busPinBuf_[i] = laneList_[i]; // data else if (pinExpanderMode() && i == latchBit_) busPinBuf_[i] = static_cast(latchPin); - else busPinBuf_[i] = derived()->clockPinForBus(); + else busPinBuf_[i] = clockPin; } return busPinBuf_; } @@ -1304,6 +1545,13 @@ class ParallelLedDriver : public DriverBase { // that much faster to hold the same 375 ns slot on the wire. The platform picks the exact rate // its clock tree can divide to (see platform_esp32_i80.cpp); this is the multiplier. uint8_t busClockMultiplier() const { return outputsPerPin(); } + // Bytes per bus slot: 1 for the 8-bit bus, 2 for the 16-bit bus. Keys on the PHYSICAL + // pin count, not laneCount_ β€” with a '595 expander the lanes ride the shift cycles, not + // extra bus bits, so 48 lanes on 6 pins is still an 8-bit bus. (In direct mode the two + // counts are equal, so this is the original behaviour.) A backend's encode trampoline + // (MoonLedDriver::ringEncodeTrampoline) branches on this through the owner_ pointer. + uint8_t slotBytes() const { return busWidthPins() > 8 ? 2 : 1; } +protected: // (Re)size the per-row correction scratch to kMaxLanes Γ— outCh bytes. Grows-only, off the hot // path (called from parseConfig). Sizing to outCh β€” not a fixed 4 β€” is what lets encodeRows lay @@ -1329,12 +1577,17 @@ class ParallelLedDriver : public DriverBase { const char* err = parsePinList(pins, laneList_, maxLanesForTarget(), n); // (Nothing to validate about the fan-out itself: pinExpander is a bool, so the only two // wirings that physically exist are the only two it can express.) - // The shift-register expander needs a backend that can DMA the 8Γ— frame from PSRAM: - // the LCD_CAM i80 path (S3 / P4). Refuse it elsewhere rather than emit a waveform the hardware - // can't sustain β€” classic-ESP32 i80 is internal-DMA-only (it walls ~76 KB; its route in is the - // PSRAM refill ring), and Parlio caps a single transfer at 65,535 B. - if (!err && pinExpanderMode() && !Derived::kSupportsPinExpander) - err = "the 74HCT595 expander needs the LCD_CAM i80 bus (ESP32-S3 / -P4)"; + // The shift-register expander needs a backend that can DMA the 8Γ— frame from PSRAM: the LCD_CAM + // i80 path (S3 / P4). On a peripheral that can't (classic-ESP32 i80 = internal-DMA-only I2S, or + // Parlio's 65,535 B single-transfer cap), SILENTLY drop back to direct mode rather than erroring: + // the `pinExpander` control is HIDDEN on such a peripheral (see supportsPinExpander), so a user + // who lands here β€” via a saved config or a peripheral switch β€” has no toggle to turn it off. An + // unfixable error status is the wrong answer; degrade to the working direct path (robust-to-any-input). + // The degrade is gated in pinExpanderMode() (the EFFECTIVE mode), NOT by mutating the stored + // `pinExpander` β€” so A/B'ing a MoonI80 '595 wall through Parlio and back keeps the saved value, and + // it comes up in shift mode again rather than silently reverting to direct (the same "don't mutate + // a persisted value to express a runtime condition" lesson as the backend-control persistence fix). + // // The latch is a real GPIO and a real bus bit; without it the '595s never present a byte. if (!err && pinExpanderMode() && latchPin < 0) err = "the 74HCT595 expander needs a latchPin"; // **The BUS width is a peripheral fact; the PIN COUNT is a board fact. They are not the same @@ -1347,7 +1600,7 @@ class ParallelLedDriver : public DriverBase { // // In shift mode the LATCH also occupies a bus bit, so it costs one of the width's lanes β€” // hence kMaxLanes - 1 data pins there against kMaxLanes here. - if constexpr (Derived::kPowerOfTwoBus) { + if (peripheral_ && peripheral_->powerOfTwoBus()) { const uint8_t maxData = static_cast(kMaxLanes - (pinExpanderMode() ? 1 : 0)); if (!err && (n == 0 || n > maxData)) err = pinExpanderMode() ? "shift mode needs 1..15 data pins (one per populated 74HCT595)" @@ -1366,15 +1619,15 @@ class ParallelLedDriver : public DriverBase { // Fatal bus-pin misconfig (MultiPinLedDriver's clockPin==dcPin β€” the i80 bus can't init) β†’ the // error path below, which idles the driver. Checked before the per-lane WARNINGS: a broken // bus is worse than a garbled lane, so it wins the status. - if (!err) err = derived()->validateBusFatal(); + if (!err && peripheral_) err = peripheral_->validateBusFatal(); // Peripheral-specific data-pin check (i80's WR/DC on a data lane routes two // output signals to one pin, so that lane emits the clock/DC waveform, not // pixel data). This is a WARNING, not a blocker: on a board that wires all // 8/16 lanes but drives fewer strands, an unused data pin is a legitimate // WR/DC candidate β€” only the lanes that actually drive a strand would show // garbage, and the user opted into that. Parlio has no WR/DC and returns null. - // Kept a CRTP hook so the base stays peripheral-neutral. - const char* warn = err ? nullptr : derived()->validateBusPins(laneList_, n); + // Kept a peripheral hook so the base stays backend-neutral. + const char* warn = (err || !peripheral_) ? nullptr : peripheral_->validateBusPins(laneList_, n); // STRAND lanes: each physical pin fans out to outputsPerPin() strands through its '595. // This is the count ledsPerPin distributes over and the encoder indexes β€” from here on // "lane" means a strand, and physPins_ holds the GPIO count. @@ -1382,12 +1635,12 @@ class ParallelLedDriver : public DriverBase { if (!err && lanes > kMaxStrands) err = "too many strands (pins Γ— 8 through the expander)"; if (!err) { // Distribute over this driver's window slice, not the whole buffer. - // assignCounts clamps each lane to kMaxWs2812LedsPerPin (drives that many - // rather than choking a whole grid onto one WS2812 line). Its own warning - // (a clamped lane) wins over the WR/DC one only if it sets warn non-null. const nrOfLightsType bufN = sourceBuffer_ ? sourceBuffer_->count() : 0; windowSlice(bufN, winStart_, winLen_); const char* clampWarn = nullptr; + // assignCounts clamps each lane to kMaxWs2812LedsPerPin (drives that many + // rather than choking a whole grid onto one WS2812 line). Its own warning + // (a clamped lane) wins over the WR/DC one only if it sets warn non-null. err = assignCounts(ledsPerPin, lanes, winLen_, laneCounts_, kMaxWs2812LedsPerPin, &clampWarn); if (clampWarn) warn = clampWarn; @@ -1433,7 +1686,17 @@ class ParallelLedDriver : public DriverBase { // row bytes. --- void reinit() { - if constexpr (Derived::lanesAvailable() == 0) return; + if (!peripheral_ || peripheral_->lanesAvailable() == 0) return; + // Peripheral-block claim guard: the chip has ONE of each block (one LcdCam, one Parlio, one + // I2S), so a second live driver on the same block would corrupt the first's DMA. If a sibling + // already holds this peripheral's block, idle with a clear status instead of fighting over the + // hardware β€” the same fail-clean spirit as the DMA-budget gate. (Two DIFFERENT peripherals β€” + // RMT + Parlio + i80 on a P4 β€” coexist fine; only same-block collides.) + if (siblingClaimsBlock()) { + deinit(); + setConfigErr("peripheral already in use by another driver β€” pick a different one"); + return; + } // A rebuild is the user fixing the setting that broke the bus: give the new bus a clean slate // so a driver that gave up starts transmitting again (the live-reconfiguration rule β€” no reboot). deadFrames_ = 0; @@ -1460,7 +1723,7 @@ class ParallelLedDriver : public DriverBase { // path, which then idles with a status if the frame won't fit either (the always-available // degrade). The ring owns its own buffers + refill task, so a plain deinit()+rebuild is correct; // ring-reuse is a later optimization (a config change already forces a rebuild). - if (derived()->wantsRing()) { + if (peripheral_->wantsRing()) { deinit(); const uint8_t outCh = correction_.outChannels; // Rows only β€” a ring buffer carries no latch pad (the reset comes from stopping the @@ -1473,20 +1736,20 @@ class ParallelLedDriver : public DriverBase { // reads the live source, so the buffer isn't needed β€” free it (ringSnapshot triggers a rebuild, // so this branch re-runs on the toggle) and skip the alloc, keeping the memory readout honest. if (!ringSnapshot) freeSnapshot(); - if (derived()->busInitRing(rowBytes, static_cast(maxLaneLights_)) + if (peripheral_->busInitRing(rowBytes, static_cast(maxLaneLights_)) && (!ringSnapshot || ensureSnapshotCap())) { inited_ = true; - dmaBuf_ = derived()->busBuffer(0); // ring[0] β€” a real pointer, the "inited" sentinel + dmaBuf_ = peripheral_->busBuffer(0); // ring[0] β€” a real pointer, the "inited" sentinel for (uint8_t i = 0; i < kMaxLanes; i++) busPins_[i] = laneList_[i]; busLaneCount_ = laneCount_; - derived()->recordBusPins(); - if (status() == Derived::kInitFailMsg) clearStatus(); + peripheral_->recordBusPins(); + if (status() == peripheral_->initFailMsg()) clearStatus(); // Re-issue the driving status WITH the ring's regime word β€” the regime (primed vs // lapping) is a platform fact that exists only now, after the ring build; parseConfig // set the plain form before it could know. Same numbers, same severity rules. // Only when the plain form is what's showing β€” a clamp warning (or any more urgent // status) must keep winning, same rule as parseConfig's `!warn` guard. - if (const char* mode = derived()->busRingMode(); + if (const char* mode = peripheral_->busRingMode(); mode && status() && std::strncmp(status(), "driving ", 8) == 0) { nrOfLightsType driven = 0; for (uint8_t i = 0; i < laneCount_; i++) driven += laneCounts_[i]; @@ -1504,7 +1767,7 @@ class ParallelLedDriver : public DriverBase { // deinit() would walk straight past, and the whole-frame busInit below would overwrite the // handle of. That leaks the scarcest memory on the chip on exactly the OOM path most likely // to hit it, and repeats on every prepare rebuild. busDeinit is idempotent and null-safe. - derived()->busDeinit(); + peripheral_->busDeinit(); deinit(); } @@ -1514,42 +1777,66 @@ class ParallelLedDriver : public DriverBase { // buffer the current path never touches. (deinit/drain above stopped any refill that read it.) freeSnapshot(); - const bool haveSecond = derived()->busBuffer(1) != nullptr; - const bool wantSecond = doubleBuffer; - if (inited_ && !derived()->busIsRing() && derived()->busCapacity() == frameBytes_ + const bool haveSecond = peripheral_->busBuffer(1) != nullptr; + // Want the second (async) buffer only when the toggle is ON AND the peripheral can run the async + // path. MoonI80 reports supportsDoubleBuffer()==false β€” its own-GDMA two-buffer handshake races + // and wedges the bus β€” so it always runs single-buffer regardless of the saved toggle (the value + // is preserved and re-engages on a peripheral that supports it). This is the single "want second?" + // decision; the reinit-skip check and the busInit request below both read it. + const bool wantSecond = doubleBuffer && peripheral_->supportsDoubleBuffer(); + if (inited_ && !peripheral_->busIsRing() && peripheral_->busCapacity() == frameBytes_ && busPinsCurrent() && busLaneCount_ == laneCount_ && haveSecond == wantSecond) { // Clear stale latch-pad bytes in BOTH buffers (buffer 1 is null in single-buffer mode). - std::memset(dmaBuf_, 0, derived()->busCapacity()); - if (uint8_t* b1 = derived()->busBuffer(1)) std::memset(b1, 0, derived()->busCapacity()); + std::memset(dmaBuf_, 0, peripheral_->busCapacity()); + if (uint8_t* b1 = peripheral_->busBuffer(1)) std::memset(b1, 0, peripheral_->busCapacity()); prefillShiftConstantsIfNeeded(); // the zeroing above wiped them return; } deinit(); - // Pass doubleBuffer so busInit allocates the second buffer only when the double-buffer is - // wanted β€” OFF (default) costs exactly one DMA buffer, no async overhead, no second alloc. - inited_ = derived()->busInit(frameBytes_, doubleBuffer); - dmaBuf_ = inited_ ? derived()->busBuffer(0) : nullptr; + // Pre-check the frame against the driver's DMA budget BEFORE attempting the bus init. A + // whole-frame driver whose DMA can't reach PSRAM (the classic ESP32 i80 = the I2S peripheral, + // internal-RAM-only) simply cannot allocate a frame larger than its internal DMA block β€” and on + // that chip the failing esp_lcd path can BUSY-WAIT to a watchdog reset rather than return an + // error. So refuse cleanly with a clear, actionable status instead of choking the init. Budget 0 + // (PSRAM-capable chips, or the streaming ring) means "no bound" β†’ always passes. Cold path. + if (const size_t budget = peripheral_->dmaBudgetBytes(); + !frameFitsDmaBudget(frameBytes_, budget)) { + // deinit() above already cleared the bus and inited_ β€” just report and bail. + if (char* b = failBufEnsure()) { + std::snprintf(b, kFailBufLen, "frame %uKB over i80 DMA %uKB: fewer lights/pin", + static_cast(frameBytes_ / 1024), + static_cast(budget / 1024)); + setStatus(b, Severity::Error); + } else { + setStatus(peripheral_->initFailMsg(), Severity::Error); + } + return; + } + // Allocate the second buffer only when wanted (see wantSecond above β€” gated on the toggle AND the + // peripheral supporting async). OFF costs exactly one DMA buffer, no async overhead. + inited_ = peripheral_->busInit(frameBytes_, wantSecond); + dmaBuf_ = inited_ ? peripheral_->busBuffer(0) : nullptr; if (inited_) { for (uint8_t i = 0; i < kMaxLanes; i++) busPins_[i] = laneList_[i]; busLaneCount_ = laneCount_; - derived()->recordBusPins(); // i80 also stores WR/DC; Parlio no-op + peripheral_->recordBusPins(); // i80 also stores WR/DC; Parlio no-op prefillShiftConstantsIfNeeded(); } if (!inited_) { clearFailBuf(); - setStatus(Derived::kInitFailMsg, Severity::Error); - } else if (status() == Derived::kInitFailMsg) { + setStatus(peripheral_->initFailMsg(), Severity::Error); + } else if (status() == peripheral_->initFailMsg()) { clearStatus(); } } void deinit() { - if constexpr (Derived::lanesAvailable() == 0) return; + if (!peripheral_ || peripheral_->lanesAvailable() == 0) return; // Free is a use-after-free if a transfer is still reading the buffer β€” drain first. // (reinit already drains before calling here; release()/onCorrectionChanged reach // deinit directly, so the guard belongs here too. Idempotent no-op when idle.) drainInFlight(); - if (inited_) derived()->busDeinit(); + if (inited_) peripheral_->busDeinit(); inited_ = false; dmaBuf_ = nullptr; active_ = 0; // next init starts on buffer 0 @@ -1569,7 +1856,7 @@ class ParallelLedDriver : public DriverBase { bool busPinsCurrent() const { for (uint8_t i = 0; i < laneCount_; i++) if (busPins_[i] != laneList_[i]) return false; - return derived()->extraBusPinsCurrent(); // i80 also checks WR/DC + return peripheral_ && peripheral_->extraBusPinsCurrent(); // i80 also checks WR/DC } // --- loopback self-test (control-driven; same status shapes as RMT). Builds @@ -1607,15 +1894,15 @@ class ParallelLedDriver : public DriverBase { patternHoldStrand_ = static_cast(strand); platform::delayMs(40); // a handful of 100 fps frames so the held pattern is on the wire before capture const uint8_t pat[3] = {kPatternRGB_[0], kPatternRGB_[1], kPatternRGB_[2]}; - const auto r = derived()->busLoopbackRide(pat, outCh < 3 ? outCh : uint8_t{3}, dataBytes, - static_cast(outCh * 8)); + const auto r = busLoopbackRide(pat, outCh < 3 ? outCh : uint8_t{3}, dataBytes, + static_cast(outCh * 8)); patternHoldStrand_ = -1; // release the hold β€” the strand returns to the live effect next frame // Report with the shared verdict formatter (same status strings as the private-bus path). reportLoopbackResult(r, outCh); } void runLoopbackSelfTest() { - if constexpr (Derived::lanesAvailable() == 0) { + if (!peripheral_ || peripheral_->lanesAvailable() == 0) { clearFailBuf(); setStatus("loopback: not supported on this platform", Severity::Warning); return; @@ -1654,7 +1941,7 @@ class ParallelLedDriver : public DriverBase { // the FULL-WIDTH private bus (it can't do a 1-lane bus), so a 16-lane driver's frame must // be 16-bit slots to match β€” and this then genuinely exercises the 16-bit transpose+DMA on // real silicon. Parlio builds a 1-lane private unit, so its loopback stays 8-bit regardless. - const uint8_t sb = Derived::kLoopbackFullWidth ? slotBytes() : 1; + const uint8_t sb = peripheral_->loopbackFullWidth() ? slotBytes() : 1; // The expander multiplies the SLOT COUNT (a '595 is serial-in: each WS2812 slot is shifted // out over 8 bus words), exactly as frameBytesFor does for the operational frame. Omitting it // here would build a frame 8Γ— too small and transmit a truncated waveform. @@ -1729,8 +2016,8 @@ class ParallelLedDriver : public DriverBase { // anything). const uint16_t realLane0 = laneList_[0]; if (loopbackTxPin >= 0 && !pinExpanderMode()) laneList_[0] = static_cast(loopbackTxPin); - const auto r = derived()->busLoopback(frame, testFrameBytes, dataBytes, - static_cast(outCh * 8)); + const auto r = peripheral_->busLoopback(frame, testFrameBytes, dataBytes, + static_cast(outCh * 8)); laneList_[0] = realLane0; platform::free(frame); // Loopback result first, then reinit: if rebuilding the real bus fails diff --git a/src/light/drivers/ParlioLedDriver.h b/src/light/drivers/ParlioLedDriver.h index 0d2a2d4b..0f2c4d1d 100644 --- a/src/light/drivers/ParlioLedDriver.h +++ b/src/light/drivers/ParlioLedDriver.h @@ -1,6 +1,6 @@ #pragma once -#include "light/drivers/ParallelLedDriver.h" // shared CRTP body +#include "light/drivers/ParallelLedDriver.h" // shared driver body + LedPeripheral #include "platform/platform.h" @@ -8,88 +8,91 @@ namespace mm { /// Output driver: parallel WS2812B over the ESP32-P4 Parlio (Parallel IO) TX peripheral β€” the P4's /// scale path, sibling of MultiPinLedDriver. The shared body (slicing, encode, single-shot DMA, loopback) -/// lives in ParallelLedDriver; Parlio is the SIMPLER peripheral, so this class adds LESS than the -/// i80 driver: +/// lives in ParallelLedDriver; Parlio is the SIMPLER peripheral, so this backend adds LESS than the +/// i80 backend: /// - NO clockPin/dcPin: Parlio generates the pixel clock itself (kClockHz), so there are no /// sacrificial WR/DC lines (addBusControls is empty). -/// - kPowerOfTwoBus = false: Parlio's bus width IS the pin count (any 1..16), so nothing rounds. The +/// - powerOfTwoBus() = false: Parlio's bus width IS the pin count (any 1..16), so nothing rounds. The /// i80 bus is 8 or 16 bits wide whatever the pin count, and parks the lanes it doesn't need on WR. /// Either way the user names only the pins that drive a strand. /// /// Prior art: the ESP32-P4 Parlio peripheral, the hpwit/FastLED parallel-WS2812 lineage β€” /// architecture studied, never copied. -class ParlioLedDriver : public ParallelLedDriver { +class ParlioPeripheral : public LedPeripheral { public: // All controls default to UNSET β€” pins="", ledsPerPin="" (= all lights on the // first lane, even-split with one lane), loopbackRxPin=-1 β€” so no constructor is - // needed (the base default-initialises them). Pins/loopback are unset because the + // needed (the orchestrator default-initialises them). Pins/loopback are unset because the // strand is user-soldered: a hard-coded pin would guess the user's wiring and // could drive a pin committed elsewhere ("default only when it cannot do harm", // see lessons.md). The P4-NANO bench uses pins "20,21,22,23,24,25,26,27", // loopbackRxPin 33 (clear of the NANO's strapping 34-38, Ethernet RMII // 28-31/49-52, C6 SDIO 14-19/54, I2C 7-8 β€” clear GPIOs are 20-27, 32-33, 39-48). - // --- CRTP hooks the base calls (all non-virtual; no vtable) --- + // --- LedPeripheral descriptors --- - /// The number of Parlio lanes this chip provides (0 = not this chip); the base's + /// The number of Parlio lanes this chip provides (0 = not this chip); the orchestrator's /// inert-on-wrong-chip guards key off it. - static constexpr uint8_t lanesAvailable() { return platform::parlioLanes; } - static constexpr bool kPowerOfTwoBus = false; // 1..16 lanes all valid + uint8_t lanesAvailable() const override { return platform::parlioLanes; } + bool powerOfTwoBus() const override { return false; } // 1..16 lanes all valid // Parlio builds a 1-lane private unit for the loopback, so the loopback frame stays 8-bit // regardless of the operational bus width (unlike i80, which needs a full-width bus). - static constexpr bool kLoopbackFullWidth = false; - static constexpr const char* kInitFailMsg = "Parlio init failed β€” check pins / memory"; + bool loopbackFullWidth() const override { return false; } + /// Parlio is its own TX peripheral block, distinct from LcdCam/I2S β€” it coexists with an i80 or + /// MoonI80 driver on the same chip (the P4 has both). + LedHwBlock hwBlock() const override { return LedHwBlock::Parlio; } + const char* initFailMsg() const override { return "Parlio init failed β€” check pins / memory"; } - // The WS2812 slot rate (375 ns @ 2.67 MHz) β€” identical to the LCD driver's; + // The WS2812 slot rate (375 ns @ 2.67 MHz) β€” identical to the LCD backend's; // the P4 Parlio's 160 MHz PLL clock divides to it exactly (/60). static constexpr uint32_t kClockHz = 2'666'666; /// No bus controls: Parlio has no sacrificial clock/DC pins (the bus rebuilds on /// a data-pin change alone). - void addBusControls() {} + void addBusControls(ControlList&) override {} /// No extra bus controls, so none can trigger a rebuild. - bool busControlTriggersBuild(const char*) const { return false; } + bool busControlTriggersBuild(const char*) const override { return false; } /// No extra pins to record (Parlio has no WR/DC). - void recordBusPins() {} + void recordBusPins() override {} /// No extra pins to track, so they are always current. - bool extraBusPinsCurrent() const { return true; } + bool extraBusPinsCurrent() const override { return true; } + + /// No 74HCT595 expander on Parlio: its single-shot transfer caps at 65,535 bytes + /// (PARLIO_LL_TX_MAX_BITS_PER_FRAME), and the Γ—8 fan-out frame is ~145 KB β€” 2.2Γ— over. The + /// orchestrator refuses shift mode here with a status rather than emitting a frame the peripheral + /// drops. (The P4's route to the expander is its LCD_CAM/i80 bus, which I80Peripheral already + /// drives; lifting this needs the chunked-transfer work, not a flag flip.) + bool supportsPinExpander() const override { return false; } /// Create the Parlio bus + its DMA buffer(s) sized for `frameBytes` on the current lanes, driving /// the pixel clock at kClockHz; `wantSecondBuffer` requests the async double-buffer's second frame /// buffer (allocated only if it fits). Returns whether init succeeded. - /// No 74HCT595 expander on Parlio: its single-shot transfer caps at 65,535 bytes - /// (PARLIO_LL_TX_MAX_BITS_PER_FRAME), and the Γ—8 fan-out frame is ~145 KB β€” 2.2Γ— over. The base - /// refuses shift mode here with a status rather than emitting a frame the peripheral drops. - /// (The P4's route to the expander is its LCD_CAM/i80 bus, which MultiPinLedDriver already drives; - /// lifting this needs the chunked-transfer work, not a flag flip.) - static constexpr bool kSupportsPinExpander = false; - - bool busInit(size_t frameBytes, bool wantSecondBuffer) { - return platform::parlioWs2812Init(parlio_, laneList_, laneCount_, + bool busInit(size_t frameBytes, bool wantSecondBuffer) override { + return platform::parlioWs2812Init(parlio_, owner_->laneList(), owner_->laneCount(), kClockHz, frameBytes, wantSecondBuffer); } - /// DMA buffer `i` (0/1) the base encodes into; buffer 1 is null when the second + /// DMA buffer `i` (0/1) the orchestrator encodes into; buffer 1 is null when the second /// buffer didn't fit (single-buffer mode). Both are the same size (busCapacity). - uint8_t* busBuffer(uint8_t i) { return platform::parlioWs2812Buffer(parlio_, i); } + uint8_t* busBuffer(uint8_t i) override { return platform::parlioWs2812Buffer(parlio_, i); } /// The per-buffer byte capacity (fixed at bus creation; both buffers equal). - size_t busCapacity() const { return platform::parlioWs2812BufferCapacity(parlio_); } + size_t busCapacity() const override { return platform::parlioWs2812BufferCapacity(parlio_); } /// Kick off the autonomous transfer of the first `bytes` of DMA buffer `i`; /// returns whether it started. - bool busTransmit(uint8_t i, size_t bytes) { return platform::parlioWs2812Transmit(parlio_, i, bytes); } + bool busTransmit(uint8_t i, size_t bytes) override { return platform::parlioWs2812Transmit(parlio_, i, bytes); } /// Block up to `ms` for buffer `i`'s in-flight transfer to complete. - bool busWait(uint8_t i, uint32_t ms) { return platform::parlioWs2812Wait(parlio_, i, ms); } + bool busWait(uint8_t i, uint32_t ms) override { return platform::parlioWs2812Wait(parlio_, i, ms); } /// The most recent DMA transfer's wire time (Β΅s) β€” the WS2812 output floor. - uint32_t busLastTransmitUs() const { return platform::parlioWs2812LastTransmitUs(parlio_); } + uint32_t busLastTransmitUs() const override { return platform::parlioWs2812LastTransmitUs(parlio_); } /// Tear down the Parlio bus and its DMA buffer. - void busDeinit() { platform::parlioWs2812Deinit(parlio_); } + void busDeinit() override { platform::parlioWs2812Deinit(parlio_); } /// Run the loopback self-test. Parlio runs on a single lane, so the loopback /// builds its own private 1-lane unit on lane 0 (no i80 full-bus workaround /// needed; no WR/DC to pass). platform::RmtLoopbackResult busLoopback(const uint8_t* frame, size_t frameBytes, - size_t dataBytes, uint8_t rowBits) { - return platform::parlioWs2812Loopback(laneList_, laneCount_, - static_cast(loopbackRxPin), + size_t dataBytes, uint8_t rowBits) override { + return platform::parlioWs2812Loopback(owner_->laneList(), owner_->laneCount(), + static_cast(owner_->loopbackRxPin), frame, frameBytes, dataBytes, rowBits); } @@ -97,4 +100,10 @@ class ParlioLedDriver : public ParallelLedDriver { platform::ParlioWs2812Handle parlio_; }; +// Register the Parlio backend into the peripheral registry once, at static-init. Gated by this header's +// CONFIG_SOC include in main.cpp (Parlio-capable silicon = the P4). No separate driver class β€” the one +// ParallelLedDriver drives it, chosen via the `peripheral` control. +inline const bool kParlioPeripheralRegistered = + ParallelLedDriver::registerPeripheral("Parlio", []() -> LedPeripheral* { return new ParlioPeripheral(); }); + } // namespace mm diff --git a/src/light/drivers/PreviewDriver.h b/src/light/drivers/PreviewDriver.h index c31051db..8c192e4f 100644 --- a/src/light/drivers/PreviewDriver.h +++ b/src/light/drivers/PreviewDriver.h @@ -59,6 +59,10 @@ class PreviewDriver : public DriverBase { /// affectsPrepare path). Lets a test drive the buffer alloc/free without a control write. void setResumableFramesForTest(bool on) { resumableFrames = on; } + /// The current adaptive downsample factor (1 = full resolution). Test-only β€” lets a test pin the + /// coarsen (additive) / refine (multiplicative) recovery cadence. + nrOfLightsType downscaleForTest() const { return downscale_; } + /// Preview shows the raw logical buffer, no correction. bool hasCorrectionControls() const override { return false; } @@ -93,6 +97,17 @@ class PreviewDriver : public DriverBase { if (broadcaster_) broadcaster_->cancelBufferedSend(); if (resumableFrames) ensureStage(); // allocate the staging buffer only when the A/B wants it else freePreviewBuffers(); // OFF: release the ~24 KB (readout drops to match) + // Re-anchor the LINK-adaptive downsample on a geometry change: a rebuild is a fresh layout, so a + // previous grid's link-struggle coarsening must not carry over and hold a now-small grid coarse + // (the "add a 16Γ—16 β†’ 4 blobs for ~10 s" bug β€” it inherited a big config's downscale_). The + // memory/display cap in buildAndSendCoordTable still sets the honest floor for THIS grid instantly + // (a 90Γ—90 lands at its 1/3 with no ramp), and downscale_ only re-coarsens if this grid's own + // frames actually stall. Reset here (the true rebuild seam), NOT in buildAndSendCoordTable, which + // the adaptive loop itself calls β€” resetting there would undo the adaptation mid-flight. + downscale_ = 1; + slowStreak_ = 0; + cleanStreak_ = 0; + framesWaiting_ = 0; // the old grid's drain count must not make the new grid's first frame read slow buildAndSendCoordTable(); refreshStatus(); // surface any resumable-path degradation (alloc miss) in the tab } @@ -174,7 +189,7 @@ class PreviewDriver : public DriverBase { // frames eventually send (the slow-but-complete case a pure all-sent signal misses β€” a // full-res 128Β² frame that delivers at ~2 fps). On a sustained run of slow frames, coarsen // the lattice (downscale_++) so frames shrink and the rate climbs; a sustained run of - // prompt, fully-sent frames refines back toward full res (downscale_--). The streaks only + // prompt, fully-sent frames refines back toward full res (downscale_ >>= 1, halving). The streaks only // advance on slots where a frame completed (sentThisSlot), so a long drain counts as ONE // slow frame, not many β€” making kDownscaleAfterSlow a count of slow frames, not ticks. // Hysteresis stops oscillation; the factor rides the wire stride field to the status line. @@ -191,7 +206,12 @@ class PreviewDriver : public DriverBase { slowStreak_ = 0; if (downscale_ > 1 && ++cleanStreak_ >= kUpscaleAfterFast) { cleanStreak_ = 0; - downscale_--; + // AIMD-inverse recovery: coarsen ADDITIVELY (+1, gentle β€” above) but refine + // MULTIPLICATIVELY (halve toward 1). A run of prompt frames means the link has plenty + // of headroom, so a coarse stride collapses to full res in ~log2 refine events, not one + // per unit β€” the difference between a small grid settling in ~1 s vs. ~10 s. The next + // step still measures before refining again, so overshoot re-coarsens by the +1 path. + downscale_ >>= 1; // guarded by downscale_ > 1 above, so this stays >= 1 buildAndSendCoordTable(); } } @@ -587,7 +607,7 @@ class PreviewDriver : public DriverBase { // The streamed send is all-or-nothing per client, so a frame (color or coord table) that // doesn't reach every client means the link can't keep up at this resolution: coarsen // (downscale_++) after a short run of such frames so the rebuilt lattice sends fewer points. - // A sustained run of fully-sent frames refines back toward full resolution (downscale_--). + // A sustained run of fully-sent frames refines back toward full resolution (downscale_ >>= 1, halving). // downscale_ is an extra floor on the per-axis lattice stride, composing with the cap // downsample; it rides the wire stride field to the browser's "preview 1/N Β· link limited" // status. (β‰₯1; 1 = full resolution.) Hysteresis via the streak thresholds stops oscillation. @@ -596,7 +616,9 @@ class PreviewDriver : public DriverBase { uint8_t cleanStreak_ = 0; // consecutive prompt, fully-sent frames uint8_t framesWaiting_ = 0; // fps slots skipped because the previous frame is still draining static constexpr uint8_t kDownscaleAfterSlow = 2; // coarsen after this many slow frames (fast react) - static constexpr uint8_t kUpscaleAfterFast = 20; // refine after this many clean frames + static constexpr uint8_t kUpscaleAfterFast = 6; // refine after this many clean frames β€” then HALVE + // downscale_ (multiplicative recovery), so a coarse + // stride reaches full res in ~log2 steps, not linearly // A frame still draining after this many fps slots means the link can't sustain even one frame // at this resolution at the slowest useful rate β†’ resolution must drop (not just the rate). Set // above 1 so a normal multi-tick drain on a healthy link isn't mistaken for struggle. diff --git a/src/light/drivers/RmtLedDriver.h b/src/light/drivers/RmtLedDriver.h index 826a723b..f5a19808 100644 --- a/src/light/drivers/RmtLedDriver.h +++ b/src/light/drivers/RmtLedDriver.h @@ -111,6 +111,7 @@ class RmtLedDriver : public DriverBase { controls_.addText("pins", pins, sizeof(pins)); controls_.addText("ledsPerPin", ledsPerPin, sizeof(ledsPerPin)); controls_.addBool("loopbackTest", loopbackTest); + controls_.setAdvanced(controls_.count() - 1); // expert-mode: a bench self-test, not a normal-use control // loopbackTxPin / loopbackRxPin are always bound (so persistence can load // them any time) but only shown while the test mode is on β€” same always- // add-then-setHidden shape NetworkModule uses for its static-IP fields. The @@ -199,6 +200,17 @@ class RmtLedDriver : public DriverBase { parseConfig(); resizeSymbols(); reinit(); + // Re-assert the resting "driving N of M lights" status after the full build. parseConfig sets it + // too, but only when a buffer is already wired (txLightCount_ > 0); on the boot path setup()'s + // parseConfig runs before the source buffer exists, so it's skipped and the status stays blank + // (or shows a stale loopback verdict) until the user touches a control. Re-deriving here β€” once + // pins + buffer + counts are all settled β€” makes it the default resting state, the way MoonLed's + // shows. Gated on inited_: reinit() reports a per-pin "RMT init failed" at Severity::Error without + // touching configErr_/configWarn_, so the `!warn` rule alone would overwrite that error with a + // false "driving N lights" while tick() bails and the strand stays dark. Only assert the resting + // status when the channels actually came up. + if (inited_ && !configErr_ && !configWarn_ && txLightCount_ > 0) + setDrivingInfo(txLightCount_, winLen_, correction_.outChannels); } /// Preset toggle (RGB↔RGBW) changes outChannels without a structural rebuild β€” @@ -403,12 +415,19 @@ class RmtLedDriver : public DriverBase { // hot path. Grows only β€” keeps a big-enough existing allocation. void resizeSymbols() { if (!sourceBuffer_) return; - // Size for this driver's window slice, not the whole source buffer β€” an - // onboard-LED slice of 1 reserves 1 light's worth of symbols, not the full - // grid's. Derive the window length directly (windowSlice is independent of - // the pin parse, so the buffer sizes correctly even before pins are set). - nrOfLightsType winStart, n; - windowSlice(sourceBuffer_->count(), winStart, n); + // Size for the lights this driver actually CLOCKS OUT, not the whole window. The window (start, + // count) can be far larger than the pins encode: `ledsPerPin` (or fewer pins than the window has + // lights) caps the transmitted total at `txLightCount_` (Ξ£ pinCounts_), and tick() only ever + // encodes that many (n = min(txLightCount_, winLen_) there). Sizing to the window instead made an + // 8Γ—8 strip on one pin (ledsPerPin 64) inside a 70Γ—82 grid (count=all, window 5740) try to alloc + // ~550 KB of symbols for lights it never encodes β€” the alloc failed on a small-heap classic ESP32, + // symbols_ stayed null, and tick() bailed β†’ the strip went dark even though only 64 lights were + // wanted. Bound to txLightCount_ so the buffer matches the real output. Fall back to the window + // when no pins are parsed yet (txLightCount_ == 0), so the buffer is ready before pins are set. + nrOfLightsType winStart, win; + windowSlice(sourceBuffer_->count(), winStart, win); + nrOfLightsType n = txLightCount_ > 0 ? txLightCount_ : win; + if (n > win) n = win; // never exceed the window's own light count const uint8_t ch = correction_.outChannels; if (n == 0 || ch == 0) return; // Per-light correction scratch: grow to `ch` bytes when the channel count grows (off the hot diff --git a/src/light/layers/Layer.h b/src/light/layers/Layer.h index 6db5ebf8..c06a3341 100644 --- a/src/light/layers/Layer.h +++ b/src/light/layers/Layer.h @@ -47,7 +47,12 @@ class Layer : public MoonModule { // value lives here so it travels with the Layer through add/delete/reorder β€” // no separate, sync-prone blend list on Drivers. The bottom (first-composited) // layer's blendMode is moot: it fills the cleared buffer regardless. - uint8_t blendMode = 0; // index into kBlendModeOptions; 0 = alpha (over) + // Default additive (index 1): a newly-added layer ADDS light onto the layers below and never + // blacks them out, which matches the common case (a sparse effect β€” sparks, a comet, text β€” + // stacked over a background). Alpha (over, index 0) is opt-in for full-frame layers that MEAN to + // cover what's below, where its black pixels are intended, not a surprise. Index order is fixed by + // kBlendModeOptions (alpha=0, additive=1) so a persisted preset's stored index keeps its meaning. + uint8_t blendMode = 1; // index into kBlendModeOptions; 1 = additive uint8_t opacity = 255; // 0 = invisible, 255 = full void defineControls() override { diff --git a/src/main.cpp b/src/main.cpp index 78ee601b..4fad5140 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -90,14 +90,17 @@ #if defined(CONFIG_SOC_RMT_SUPPORTED) #include "light/drivers/RmtLedDriver.h" #endif +// The parallel-WS2812 driver + its peripheral backends. Each backend header self-registers its factory +// into ParallelLedDriver's peripheral registry (gated by the chip's CONFIG_SOC_*), so including the ones +// this silicon supports is what populates the `peripheral` control's options. #if defined(CONFIG_SOC_LCD_I80_SUPPORTED) -#include "light/drivers/MultiPinLedDriver.h" +#include "light/drivers/MultiPinLedDriver.h" // esp_lcd i80 backend (I80Peripheral) #endif #if defined(CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED) -#include "light/drivers/MoonLedDriver.h" +#include "light/drivers/MoonLedDriver.h" // MoonI80 own-GDMA backend (MoonI80Peripheral) #endif #if defined(CONFIG_SOC_PARLIO_SUPPORTED) -#include "light/drivers/ParlioLedDriver.h" +#include "light/drivers/ParlioLedDriver.h" // Parlio backend (ParlioPeripheral) #endif #include "core/HttpServerModule.h" #include "core/SystemModule.h" @@ -131,6 +134,12 @@ static void registerModuleTypes() { mm::ModuleFactory::registerType("Layer", "light/supporting.md#layer"); mm::ModuleFactory::registerType("Drivers", "light/supporting.md#drivers"); mm::ModuleFactory::registerType("LightPresetsModule", "light/supporting.md#lightpresets"); + + // Wire the core quiesce-render hook to the light domain's encode worker: before core mutates the tree + // (add/remove/replace a child), stop core 1 so it can't dereference a node being freed. Core can't + // name Drivers (a light module), so it calls through this function-pointer seam (see MoonModule + // quiesceForMutation). Wired once here, where main.cpp legitimately depends on both sides. + mm::MoonModule::setQuiesceRenderHook([] { if (auto* d = mm::Drivers::active()) d->quiesceRenderSplit(); }); // Concrete modules. registerType captures the type's dimensions() via // if-constexpr when present β€” EffectBase and ModifierBase both expose one, // so the UI's πŸ“/🟦/🧊 chip lights up without any per-domain wrapper. @@ -214,19 +223,13 @@ static void registerModuleTypes() { #if defined(CONFIG_SOC_RMT_SUPPORTED) mm::ModuleFactory::registerType("RmtLedDriver", "light/drivers.md#rmtled"); #endif - // MultiPinLedDriver β€” 8/16 parallel strands over IDF's esp_lcd i80 bus (LCD_CAM on S3/P4, I2S-i80 - // on classic ESP32); IDF picks the backend by chip, so ONE driver serves all i80-capable silicon. -#if defined(CONFIG_SOC_LCD_I80_SUPPORTED) - mm::ModuleFactory::registerType("MultiPinLedDriver", "light/drivers.md#multipinled"); -#endif -#if defined(CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED) - // The same LCD_CAM output on our own DMA code instead of esp_lcd (ADR-0014). Registered ALONGSIDE - // MultiPinLedDriver, not instead of it: that one is the reference implementation and the default, - // this is the challenger, and having both registered makes the A/B a swap in the UI. - mm::ModuleFactory::registerType("MoonLedDriver", "light/drivers.md#moonled"); -#endif -#if defined(CONFIG_SOC_PARLIO_SUPPORTED) - mm::ModuleFactory::registerType("ParlioLedDriver", "light/drivers.md#parlioled"); + // ParallelLedDriver β€” ONE driver for the parallel-WS2812 output, whatever the DMA peripheral. The + // three backends (esp_lcd i80, MoonI80 own-GDMA, Parlio) each self-register into the driver's + // peripheral registry when their header is included above (gated by the same CONFIG_SOC_* below), so + // the `peripheral` control offers exactly the ones this chip links. Registered once, on any chip that + // links at least one parallel backend. +#if defined(CONFIG_SOC_LCD_I80_SUPPORTED) || defined(CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED) || defined(CONFIG_SOC_PARLIO_SUPPORTED) + mm::ModuleFactory::registerType("ParallelLedDriver", "light/drivers.md#parallelled"); #endif mm::ModuleFactory::registerType("HttpServerModule", "core/system.md"); mm::ModuleFactory::registerType("SystemModule", "core/system.md#system"); @@ -548,6 +551,17 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) { lastLog = now; if (scheduler.tickTimeUs() == 0) continue; // no measurement yet + // The KPI tick line is a plain stdout printf, not an ESP_LOG, so the platform log level + // doesn't suppress it β€” we gate it here on the same level. At Info or above it prints; at + // Warn/Error/None it's silenced so a device resting quietly makes no periodic serial write + // (a status LED that blinks on UART TX stops flickering). The first 60 s of uptime always + // prints regardless: the web installer reads MM_IP off this line just after flash, and the + // window latches the same way the MM_IP token below does (a plain `< 60000` re-opens every + // ~49.7 days at the millis() wrap). Real ESP_LOGW/ESP_LOGE warnings and errors are a + // separate stream that setLogLevel governs independently, so they still surface at Warn. + const bool inBootWindow = !mmIpWindowClosed && (now - bootMillis < 60000); + if (systemModule->logLevel() < mm::platform::LogLevel::Info && !inBootWindow) continue; + heap = mm::platform::freeHeap(); std::printf("tick: %uus (FPS: %u)", static_cast(scheduler.tickTimeUs()), static_cast(scheduler.fps())); diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 35340d01..9c79cbc6 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -434,6 +434,11 @@ const char* resetReason() { return "OK"; } +void setLogLevel(LogLevel) { + // Desktop logs to the terminal unconditionally; the KPI-line gate reads the level directly, + // so there is nothing to apply to a platform logger here. +} + size_t firmwareSize() { return 0; } size_t firmwarePartition() { return 0; } size_t flashChipSize() { return 0; } diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h index aed9f357..2f2d4511 100644 --- a/src/platform/esp32/platform_config.h +++ b/src/platform/esp32/platform_config.h @@ -88,7 +88,7 @@ constexpr uint8_t rmtTxChannels = 0; // unrelated I2S-LCD peripheral, so gating on it wired this driver onto the // classic chip and hung its boot trying to init an esp_lcd i80 bus the chip // doesn't have. SOC_LCDCAM_I80_LCD_SUPPORTED is defined only on chips with the -// real LCD_CAM (S3/P4), which is what esp_lcd's i80 driver actually needs. +// real LCD_CAM (S3/P4/S31), which is what esp_lcd's i80 driver actually needs. // The LCD_CAM i80 bus does 16 data lines. The driver derives the actual bus width // (8 or 16 β€” power-of-two only) from the configured pin count; this is the MAX it // may reach. LCD requires exactly 8 or 16 real pins (i80 rejects an NC data line). @@ -116,8 +116,8 @@ constexpr uint8_t parlioLanes = 0; // width, WR/DC) with the I2S peripheral on the classic ESP32 (esp_lcd_panel_io_i2s.c), // using WHOLE-FRAME chained DMA β€” so MultiPinLedDriver reuses the MultiPinLedDriver code path and // the i80Ws2812* seam, not a bespoke ISR ring. Gate CLASSIC-ONLY: SOC_LCD_I80_SUPPORTED -// is set on the classic chip (I2S backend) AND the S3/P4 (LCD_CAM backend), so exclude -// the LCD_CAM chips β€” otherwise both this and lcdLanes would be non-zero on the S3/P4 and +// is set on the classic chip (I2S backend) AND the LCD_CAM chips (S3/P4/S31, LCD_CAM backend), so +// exclude the LCD_CAM chips β€” otherwise both this and lcdLanes would be non-zero on those chips and // the chip would register both drivers. The `defined(A) && !defined(B)` shape mirrors // hasEthW5500 below. The i80 bus does 16 data lines; the driver derives 8 or 16 from the // pin count and requires exactly that many real pins (i80 rejects an NC data line). diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp index 74f038a0..5b1a7014 100644 --- a/src/platform/esp32/platform_esp32.cpp +++ b/src/platform/esp32/platform_esp32.cpp @@ -364,6 +364,12 @@ const char* resetReason() { } } +void setLogLevel(LogLevel level) { + // LogLevel's values are chosen to equal esp_log_level_t (None=0 … Verbose=5), so the + // mapping is a plain cast β€” the "*" tag sets the level for every component at once. + esp_log_level_set("*", static_cast(level)); +} + size_t firmwareSize() { // Get actual running image size from the image header const esp_partition_t* part = esp_ota_get_running_partition(); diff --git a/src/platform/esp32/platform_esp32_moon_i80.cpp b/src/platform/esp32/platform_esp32_moon_i80.cpp index 73eb2419..6bcd39b4 100644 --- a/src/platform/esp32/platform_esp32_moon_i80.cpp +++ b/src/platform/esp32/platform_esp32_moon_i80.cpp @@ -566,6 +566,10 @@ bool IRAM_ATTR moonI80EofCb(gdma_channel_handle_t, gdma_event_data_t*, void* use } // Whole-frame mode: pop the oldest started buffer, record its wire time, release its waiter. + // Guard on a non-empty FIFO: if the wait-timeout backstop already drained this entry (fifoTail == + // fifoHead) and the lost EOF then arrives late, popping would read a stale slot, hand out a spurious + // `done`, and desync tail past head β€” so a firing against a structurally empty FIFO is dropped. + if (st->fifoTail == st->fifoHead) return false; const uint8_t slot = st->fifoTail; const uint8_t b = st->fifo[slot] & 1u; const int64_t now = esp_timer_get_time(); @@ -1417,6 +1421,23 @@ MoonI80State* createRingState(const uint16_t* dataPins, uint8_t laneCount, uint1 return st; } +// Abandon the in-flight transfer and return the peripheral to a clean idle β€” the ONE recovery both the +// ring and whole-frame wait-timeout backstops share. A lost/coalesced EOF leaves `busy` stuck true with +// no interrupt coming to clear it; without this the bus wedges permanently (every later transmit blocks +// its full timeout on the stuck busy, and the driver's give-up retry re-arms into the same stuck state). +// The single owner of "the EOF didn't come, unstick the bus": stop the LCD + GDMA, clear busy, and mark +// the strand as idling LOW now (the WS2812 reset begins here) so the next arm holds the reset window. +// The CONDITION for finalizing, and any mode-specific residue (the ring re-links its chain and latches +// encode stats on the next arm; the whole-frame path drains its completion FIFO), stay at the call sites β€” +// only the shared stop-and-clear lives here, so the two paths can't drift in how they leave the hardware. +void finalizeStalledTransfer(MoonI80State* st) { + lcd_ll_stop(st->hal.dev); + gdma_stop(st->dma); + st->busy = false; + st->lastStopUs = esp_timer_get_time(); + st->dbgStallAbandons = st->dbgStallAbandons + 1u; +} + } // namespace bool moonI80Ws2812Init(MoonI80Ws2812Handle& h, const uint16_t* dataPins, uint8_t laneCount, @@ -1649,17 +1670,26 @@ bool moonI80Ws2812Wait(MoonI80Ws2812Handle& h, uint8_t buffer, uint32_t timeoutM && st->lastWrittenSlice < st->nSlices + kTailBufs) { // kTailBufs (file scope), not a bare +1 const int64_t frameWireUs = (static_cast(st->nSlices) * st->sliceNs) / 1000; if (esp_timer_get_time() - st->armUs >= frameWireUs) { - lcd_ll_stop(st->hal.dev); - gdma_stop(st->dma); - st->busy = false; - st->lastStopUs = esp_timer_get_time(); // the strand idles LOW β†’ the WS2812 reset begins - st->dbgStallAbandons = st->dbgStallAbandons + 1u; + finalizeStalledTransfer(st); // the shared stop-and-clear; the next arm re-links the chain // Latch whatever this frame's refills managed, so the ea readout isn't stuck at a half window. st->dbgEncAvgUs = st->dbgEncCount ? st->dbgEncSumUs / st->dbgEncCount : st->dbgEncAvgUs; st->dbgEncSumUs = 0; st->dbgEncCount = 0; } } + + // STALL BACKSTOP (whole-frame path). The EOF interrupt is a latch that can, very rarely, be lost β€” + // two firings coalescing, or one racing the next frame's reset β€” leaving `busy` stuck true with no + // EOF coming to clear it. Without recovery the bus wedges permanently: every later transmit sees busy + // and blocks its full wire-free timeout, so the driver's give-up retry re-arms into the same stuck + // state (the ~5 s-then-dark wedge on a direct strand). The ring branch above finalizes its own stall + // on the oracle's condition; here the condition is simply "the wait timed out with a transfer in + // flight." Shared stop-and-clear via finalizeStalledTransfer; the whole-frame residue is draining the + // completion FIFO so the abandoned entry can't be popped by a late EOF against the next frame. + if (!st->isRing && st->busy) { + finalizeStalledTransfer(st); // stop LCD + GDMA FIRST, so no EOF can fire during the drain below + st->fifoTail = st->fifoHead; // then drop the un-completed entry; the ISR guard ignores a late EOF + } return false; // the caller keeps the buffer in-flight for this frame; the next frame arms fresh } diff --git a/src/platform/platform.h b/src/platform/platform.h index a0c36a04..63cd5379 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -257,6 +257,16 @@ const char* hostIp(); // "BROWNOUT", "DEEPSLEEP", or "UNKNOWN". On desktop always returns "OK". UI uses // this to flag a "crashed" prior boot (PANIC / INT_WDT / TASK_WDT / BROWNOUT). const char* resetReason(); + +// Serial log verbosity, low to high. Mirrors the standard syslog/ESP-IDF ordering so the +// numeric value maps straight onto esp_log_level_set (None=0 … Verbose=5). The periodic KPI +// tick line (a plain stdout printf, not an ESP_LOG) is emitted only at Info or above, so a +// resting device at Warn stays quiet on the wire β€” no once-a-second serial write β€” while real +// ESP_LOGW/ESP_LOGE warnings and errors still print. setLogLevel applies it to the ESP-IDF +// logger; the KPI-line gate is read from the same value in the main loop. Desktop is a no-op. +enum class LogLevel : uint8_t { None = 0, Error, Warn, Info, Debug, Verbose }; +void setLogLevel(LogLevel level); + size_t firmwareSize(); // firmware image bytes size_t firmwarePartition(); // app partition size (firmware capacity) size_t flashChipSize(); // total flash chip capacity diff --git a/src/ui/app.js b/src/ui/app.js index e225fbf5..0b8b9ac3 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -426,12 +426,43 @@ async function addModule(type, parentName) { if (!type) return; const body = {type: type}; if (parentName) body.parent_id = parentName; - await fetch("/api/modules", { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify(body) - }); - refetchState(); + let name = null; + try { + const r = await fetch("/api/modules", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(body) + }); + name = (await r.json()).name; // the created module's final name (post-disambiguation) + } catch {} + // Select the new module's tab BEFORE the re-render so renderCards shows it active (the tab strip + // reads selectedTabs[parent]); then scroll it into view and focus its first control so a keyboard + // user lands on it. Without this the view stays on the previously-active tab and the new module + // is added out of sight. + if (name && parentName) { + selectedTabs[parentName] = name; + localStorage.setItem(LS_TABS, JSON.stringify(selectedTabs)); // persist like the tab-click path + } + await refetchState(); + if (name) focusModule(name); +} + +// Bring a module's card into view and focus its first control (added via the "+" flow). +function focusModule(name) { + const card = document.querySelector(`.card[data-module="${cssEscape(name)}"]`); + if (!card) return; + // A child card can wrap its controls in a collapsed
(.card-controls-collapse) β€” open it + // FIRST so the card is at its expanded height, THEN scroll: scrolling a still-collapsed card lands + // on its pre-expansion geometry and the focused control ends up mispositioned. + const collapse = card.querySelector("details.card-controls-collapse"); + if (collapse) collapse.open = true; + card.scrollIntoView({ block: "nearest", behavior: "smooth" }); + // Focus the first real control input, NOT the tab strip / header buttons β€” and NOT the status row, + // which is a `.control-row` with only spans (a freshly added driver leads with a status, so picking + // the first `.control-row` would find no input and focus nothing). Query for the input directly + // inside any control row so the status row is skipped. + const first = card.querySelector(".control-row input, .control-row select, .control-row textarea, .control-row button"); + if (first) first.focus({ preventScroll: true }); } async function deleteModule(name) { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 94241cb6..a001d532 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -113,6 +113,7 @@ add_executable(mm_tests unit/light/unit_ParallelLedDriver_doublebuffer.cpp unit/light/unit_ParallelLedDriver_pinexpander.cpp unit/light/unit_ParallelLedDriver_ring.cpp + unit/light/unit_ParallelLedDriver_swap.cpp unit/light/unit_RmtLedEncoder.cpp unit/light/unit_RmtLedDriver_lifecycle.cpp unit/light/unit_RmtLedDriver_pins.cpp diff --git a/test/scenarios/core/scenario_DevicesModule_scan.json b/test/scenarios/core/scenario_DevicesModule_scan.json deleted file mode 100644 index e03986ed..00000000 --- a/test/scenarios/core/scenario_DevicesModule_scan.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "scenario_DevicesModule_scan", - "module": "DevicesModule", - "mode": "mutate", - "live_only": true, - "description": "Trigger the device-discovery sweep repeatedly on a running device and confirm the render loop survives every sweep. Pins the robustness principle for DevicesModule: pressing the `scan` button (a Button control on the Devices submodule of Network) re-runs the subnet sweep, whose HTTP probes block the render task up to the probe timeout per tick. No press, sweep state, or completion may crash or wedge the tick. Runs live only β€” discovery needs a real LAN to probe and the module only exists on a connected device, so the in-process desktop runner SKIPs it; on a device it presses the button over HTTP. The bound checks FPS stays within range across repeated sweeps (a sweep is a transient cost, not a permanent degradation), proving the scan never leaves the render loop in a wedged state. (Background: the sweep is boot-only + manual precisely because the blocking probe must not run continuously on the render task β€” see DevicesModule.md.)", - "reset": [ - { - "name": "reset-grid-width", - "op": "set_control", - "id": "Grid", - "key": "width", - "value": 64 - }, - { - "name": "reset-grid-height", - "op": "set_control", - "id": "Grid", - "key": "height", - "value": 64 - } - ], - "steps": [ - { - "name": "scan-1", - "description": "First manual sweep. Baseline: the device renders while a discovery sweep runs.", - "op": "set_control", - "id": "Devices", - "key": "scan", - "value": 1, - "measure": true - }, - { - "name": "scan-2", - "description": "Re-trigger the sweep while the previous one's state is still settling β€” confirms a re-press mid-cycle doesn't wedge the loop.", - "op": "set_control", - "id": "Devices", - "key": "scan", - "value": 1, - "measure": true - }, - { - "name": "scan-3", - "description": "Third sweep, bounded: FPS must stay within 20% of the first (a sweep is a transient cost; repeated scans must not permanently degrade the render loop).", - "op": "set_control", - "id": "Devices", - "key": "scan", - "value": 1, - "measure": true, - "bounds": { - "fps": { - "min_pct": 80 - } - } - } - ] -} diff --git a/test/scenarios/core/scenario_MoonModule_control_change.json b/test/scenarios/core/scenario_MoonModule_control_change.json index 988799ed..e0070c4d 100644 --- a/test/scenarios/core/scenario_MoonModule_control_change.json +++ b/test/scenarios/core/scenario_MoonModule_control_change.json @@ -171,7 +171,7 @@ }, "esp32": { "tick_us": [ - 225, + 212, 253513 ], "free_heap": [ @@ -184,7 +184,7 @@ ], "at": [ "2026-06-02", - "2026-07-22" + "2026-07-24" ] }, "desktop-windows": { @@ -222,6 +222,24 @@ "2026-06-17", "2026-06-17" ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 166, + 270 + ], + "free_heap": [ + 8518083, + 8534351 + ], + "max_alloc_block": [ + 86016, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -304,7 +322,7 @@ }, "esp32": { "tick_us": [ - 236, + 212, 209451 ], "free_heap": [ @@ -317,7 +335,7 @@ ], "at": [ "2026-06-02", - "2026-07-22" + "2026-07-24" ] }, "desktop-windows": { @@ -355,6 +373,24 @@ "2026-06-17", "2026-06-17" ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 153, + 206 + ], + "free_heap": [ + 8521191, + 8534163 + ], + "max_alloc_block": [ + 86016, + 90112 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -437,7 +473,7 @@ }, "esp32": { "tick_us": [ - 240, + 183, 226151 ], "free_heap": [ @@ -450,7 +486,7 @@ ], "at": [ "2026-06-02", - "2026-07-22" + "2026-07-24" ] }, "desktop-windows": { @@ -488,6 +524,24 @@ "2026-06-17", "2026-06-17" ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 154, + 206 + ], + "free_heap": [ + 8516495, + 8534563 + ], + "max_alloc_block": [ + 81920, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -578,7 +632,7 @@ }, "esp32": { "tick_us": [ - 233, + 186, 229353 ], "free_heap": [ @@ -591,7 +645,7 @@ ], "at": [ "2026-06-02", - "2026-07-22" + "2026-07-24" ] }, "desktop-windows": { @@ -629,6 +683,24 @@ "2026-06-17", "2026-06-17" ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 147, + 235 + ], + "free_heap": [ + 8521803, + 8535619 + ], + "max_alloc_block": [ + 86016, + 90112 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } } diff --git a/test/scenarios/core/scenario_MqttModule_haDiscovery_toggle.json b/test/scenarios/core/scenario_MqttModule_haDiscovery_toggle.json index 64cab041..b365af6f 100644 --- a/test/scenarios/core/scenario_MqttModule_haDiscovery_toggle.json +++ b/test/scenarios/core/scenario_MqttModule_haDiscovery_toggle.json @@ -34,10 +34,10 @@ "esp32": { "tick_us": [ 34, - 38 + 2690 ], "free_heap": [ - 132472, + 119700, 137912 ], "max_alloc_block": [ @@ -46,7 +46,25 @@ ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 34, + 99032 + ], + "free_heap": [ + 7647119, + 8540795 + ], + "max_alloc_block": [ + 86016, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -71,10 +89,10 @@ "esp32": { "tick_us": [ 36, - 38 + 2706 ], "free_heap": [ - 131556, + 119700, 139408 ], "max_alloc_block": [ @@ -83,7 +101,25 @@ ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 36, + 100148 + ], + "free_heap": [ + 7579123, + 8542067 + ], + "max_alloc_block": [ + 86016, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -108,10 +144,10 @@ "esp32": { "tick_us": [ 35, - 38 + 2772 ], "free_heap": [ - 131524, + 120756, 139712 ], "max_alloc_block": [ @@ -120,7 +156,25 @@ ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 35, + 92237 + ], + "free_heap": [ + 7646455, + 8542067 + ], + "max_alloc_block": [ + 86016, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } diff --git a/test/scenarios/core/scenario_NetworkModule_eth_reconfigure.json b/test/scenarios/core/scenario_NetworkModule_eth_reconfigure.json index 670a5f8f..24c47dd4 100644 --- a/test/scenarios/core/scenario_NetworkModule_eth_reconfigure.json +++ b/test/scenarios/core/scenario_NetworkModule_eth_reconfigure.json @@ -39,20 +39,38 @@ "observed": { "esp32": { "tick_us": [ - 6869, + 978, 7491 ], "free_heap": [ - 168788, + 102424, 168792 ], "max_alloc_block": [ - 110592, + 49152, 110592 ], "at": [ "2026-06-15", - "2026-06-15" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 87622, + 107729 + ], + "free_heap": [ + 7642887, + 8219251 + ], + "max_alloc_block": [ + 81920, + 90112 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -68,20 +86,38 @@ "observed": { "esp32": { "tick_us": [ - 6825, + 979, 6916 ], "free_heap": [ - 168788, + 102292, 168788 ], "max_alloc_block": [ - 110592, + 49152, 110592 ], "at": [ "2026-06-15", - "2026-06-15" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 95648, + 97699 + ], + "free_heap": [ + 7646031, + 8150319 + ], + "max_alloc_block": [ + 86016, + 90112 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -97,20 +133,38 @@ "observed": { "esp32": { "tick_us": [ - 7401, + 980, 7504 ], "free_heap": [ - 168788, + 102624, 168788 ], "max_alloc_block": [ - 110592, + 49152, 110592 ], "at": [ "2026-06-15", - "2026-06-15" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 97843, + 98482 + ], + "free_heap": [ + 7646459, + 8220227 + ], + "max_alloc_block": [ + 86016, + 90112 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -126,20 +180,38 @@ "observed": { "esp32": { "tick_us": [ - 6792, + 993, 6861 ], "free_heap": [ - 168788, + 102644, 168788 ], "max_alloc_block": [ - 110592, + 49152, 110592 ], "at": [ "2026-06-15", - "2026-06-15" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 92572, + 96153 + ], + "free_heap": [ + 7620419, + 8192643 + ], + "max_alloc_block": [ + 61440, + 65536 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -155,20 +227,38 @@ "observed": { "esp32": { "tick_us": [ - 6911, + 1169, 7475 ], "free_heap": [ - 168788, + 102624, 168788 ], "max_alloc_block": [ - 110592, + 49152, 110592 ], "at": [ "2026-06-15", - "2026-06-15" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 90430, + 90504 + ], + "free_heap": [ + 7644083, + 8218479 + ], + "max_alloc_block": [ + 86016, + 90112 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } diff --git a/test/scenarios/core/scenario_NetworkModule_mdns_toggle.json b/test/scenarios/core/scenario_NetworkModule_mdns_toggle.json index 55d2dc00..798e398f 100644 --- a/test/scenarios/core/scenario_NetworkModule_mdns_toggle.json +++ b/test/scenarios/core/scenario_NetworkModule_mdns_toggle.json @@ -102,6 +102,24 @@ "2026-06-17", "2026-06-17" ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 36, + 110678 + ], + "free_heap": [ + 7577871, + 8541015 + ], + "max_alloc_block": [ + 81920, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -193,6 +211,24 @@ "2026-06-17", "2026-06-17" ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 36, + 118324 + ], + "free_heap": [ + 7648175, + 8541395 + ], + "max_alloc_block": [ + 86016, + 90112 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -284,6 +320,24 @@ "2026-06-17", "2026-06-17" ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 35, + 97830 + ], + "free_heap": [ + 7619747, + 8541051 + ], + "max_alloc_block": [ + 61440, + 90112 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } } diff --git a/test/scenarios/light/scenario_Audio_mutation.json b/test/scenarios/light/scenario_Audio_mutation.json index 92cbe718..ee7b1ac9 100644 --- a/test/scenarios/light/scenario_Audio_mutation.json +++ b/test/scenarios/light/scenario_Audio_mutation.json @@ -47,13 +47,23 @@ "height": 64 } }, + { + "name": "fix-layers", + "description": "Layers: the top-level container the Layer hangs under β€” the standard tree the live device boots with. A pinned single Layer (parent_id-less, props.layouts) is the older shape the host runner tolerates but the device's live reachability check does not.", + "op": "add_module", + "id": "Layers", + "type": "Layers", + "props": { + "layouts": "Layouts" + } + }, { "name": "fix-layer", "op": "add_module", "id": "Layer", "type": "Layer", + "parent_id": "Layers", "props": { - "layouts": "Layouts", "channelsPerLight": 3 } }, @@ -70,7 +80,7 @@ "id": "Drivers", "type": "Drivers", "props": { - "layer": "Layer" + "layers": "Layers" } }, { @@ -132,19 +142,37 @@ "esp32": { "tick_us": [ 30, - 32 + 912 ], "free_heap": [ - 132244, - 133524 + 97484, + 134324 ], "max_alloc_block": [ - 110592, + 49152, 110592 ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 34, + 96487 + ], + "free_heap": [ + 7579339, + 8541659 + ], + "max_alloc_block": [ + 86016, + 90112 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -206,20 +234,38 @@ }, "esp32": { "tick_us": [ - 35, - 37 + 30, + 967 ], "free_heap": [ - 127468, - 127716 + 91320, + 127772 ], "max_alloc_block": [ - 110592, + 73728, 110592 ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 38, + 87069 + ], + "free_heap": [ + 7646227, + 8535767 + ], + "max_alloc_block": [ + 86016, + 90112 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -297,20 +343,38 @@ }, "esp32": { "tick_us": [ - 32, - 45 + 26, + 1091 ], "free_heap": [ - 130620, - 130904 + 94188, + 136324 ], "max_alloc_block": [ - 110592, + 90112, 110592 ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 47, + 96332 + ], + "free_heap": [ + 7579367, + 8528955 + ], + "max_alloc_block": [ + 77824, + 86016 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -372,20 +436,38 @@ }, "esp32": { "tick_us": [ - 33, - 36 + 26, + 1460 ], "free_heap": [ - 115928, - 128660 + 93556, + 135688 ], "max_alloc_block": [ - 110592, + 90112, 110592 ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 46, + 101101 + ], + "free_heap": [ + 7556343, + 8528959 + ], + "max_alloc_block": [ + 61440, + 86016 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -446,19 +528,37 @@ "esp32": { "tick_us": [ 33, - 34 + 1463 ], "free_heap": [ - 133756, - 134328 + 99096, + 135048 ], "max_alloc_block": [ - 110592, + 94208, 110592 ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 37, + 103475 + ], + "free_heap": [ + 7659423, + 8541875 + ], + "max_alloc_block": [ + 86016, + 90112 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -519,19 +619,37 @@ "esp32": { "tick_us": [ 31, - 32 + 974 ], "free_heap": [ - 133604, - 133632 + 98596, + 134416 ], "max_alloc_block": [ - 110592, + 94208, 110592 ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 34, + 95335 + ], + "free_heap": [ + 7661059, + 8541451 + ], + "max_alloc_block": [ + 86016, + 90112 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } diff --git a/test/scenarios/light/scenario_Driver_mutation.json b/test/scenarios/light/scenario_Driver_mutation.json index fcc62e90..b1d898f6 100644 --- a/test/scenarios/light/scenario_Driver_mutation.json +++ b/test/scenarios/light/scenario_Driver_mutation.json @@ -113,19 +113,37 @@ "esp32": { "tick_us": [ 31, - 32 + 906 ], "free_heap": [ - 130936, + 98872, 131360 ], "max_alloc_block": [ - 110592, + 94208, 110592 ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 34, + 99325 + ], + "free_heap": [ + 7647399, + 8541247 + ], + "max_alloc_block": [ + 86016, + 86016 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -188,19 +206,37 @@ "esp32": { "tick_us": [ 38, - 49 + 1130 ], "free_heap": [ - 127304, + 84592, 132340 ], "max_alloc_block": [ - 110592, + 81920, 110592 ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 38, + 97522 + ], + "free_heap": [ + 7632103, + 8539675 + ], + "max_alloc_block": [ + 61440, + 86016 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -263,19 +299,37 @@ "esp32": { "tick_us": [ 37, - 40 + 1001 ], "free_heap": [ - 129880, + 70784, 130152 ], "max_alloc_block": [ - 110592, + 65536, 110592 ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 39, + 96283 + ], + "free_heap": [ + 7620119, + 8538115 + ], + "max_alloc_block": [ + 73728, + 86016 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -336,19 +390,37 @@ "esp32": { "tick_us": [ 36, - 39 + 980 ], "free_heap": [ - 131512, + 84568, 132408 ], "max_alloc_block": [ - 110592, + 69632, 110592 ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 36, + 111657 + ], + "free_heap": [ + 7634063, + 8540315 + ], + "max_alloc_block": [ + 86016, + 86016 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -409,19 +481,37 @@ "esp32": { "tick_us": [ 35, - 38 + 988 ], "free_heap": [ - 133328, + 98560, 134264 ], "max_alloc_block": [ - 110592, + 94208, 110592 ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 36, + 112288 + ], + "free_heap": [ + 7648471, + 8542087 + ], + "max_alloc_block": [ + 86016, + 86016 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } diff --git a/test/scenarios/light/scenario_GridBlacks_blackpixel.json b/test/scenarios/light/scenario_GridBlacks_blackpixel.json index 973a6317..7b096a4f 100644 --- a/test/scenarios/light/scenario_GridBlacks_blackpixel.json +++ b/test/scenarios/light/scenario_GridBlacks_blackpixel.json @@ -91,7 +91,7 @@ "desktop-macos": { "tick_us": [ 1, - 2 + 3 ], "free_heap": [ 0, @@ -103,7 +103,43 @@ ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-23" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 176, + 239 + ], + "free_heap": [ + 8525579, + 8538847 + ], + "max_alloc_block": [ + 81920, + 86016 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32": { + "tick_us": [ + 243, + 243 + ], + "free_heap": [ + 122696, + 122696 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -136,7 +172,7 @@ "desktop-macos": { "tick_us": [ 1, - 3 + 5 ], "free_heap": [ 0, @@ -148,7 +184,43 @@ ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-23" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 267, + 354 + ], + "free_heap": [ + 8519875, + 8536455 + ], + "max_alloc_block": [ + 81920, + 90112 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32": { + "tick_us": [ + 269, + 269 + ], + "free_heap": [ + 121548, + 121548 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } diff --git a/test/scenarios/light/scenario_GridLayout_resize.json b/test/scenarios/light/scenario_GridLayout_resize.json index 9c26b977..6f2f7215 100644 --- a/test/scenarios/light/scenario_GridLayout_resize.json +++ b/test/scenarios/light/scenario_GridLayout_resize.json @@ -171,20 +171,20 @@ }, "esp32": { "tick_us": [ - 1562, + 1352, 224420 ], "free_heap": [ 85252, - 101404 + 103244 ], "max_alloc_block": [ 49152, - 86016 + 90112 ], "at": [ "2026-06-02", - "2026-07-22" + "2026-07-24" ] }, "desktop-windows": { @@ -225,20 +225,20 @@ }, "esp32s3-n16r8": { "tick_us": [ - 4601, + 1011, 9422 ], "free_heap": [ - 8514887, + 8492479, 8520587 ], "max_alloc_block": [ - 106496, + 77824, 110592 ], "at": [ "2026-06-22", - "2026-06-22" + "2026-07-24" ] } } @@ -322,20 +322,20 @@ }, "esp32": { "tick_us": [ - 654, + 573, 90162 ], "free_heap": [ 64992, - 107856 + 109464 ], "max_alloc_block": [ 17408, - 86016 + 90112 ], "at": [ "2026-06-02", - "2026-07-22" + "2026-07-24" ] }, "desktop-windows": { @@ -376,20 +376,20 @@ }, "esp32s3-n16r8": { "tick_us": [ - 1270, + 478, 2411 ], "free_heap": [ - 8524023, + 8509019, 8530563 ], "max_alloc_block": [ - 102400, + 86016, 114688 ], "at": [ "2026-06-22", - "2026-06-22" + "2026-07-24" ] } } @@ -478,15 +478,15 @@ ], "free_heap": [ 85252, - 102116 + 103252 ], "max_alloc_block": [ 53248, - 86016 + 90112 ], "at": [ "2026-06-02", - "2026-07-22" + "2026-07-24" ] }, "desktop-windows": { @@ -527,20 +527,20 @@ }, "esp32s3-n16r8": { "tick_us": [ - 3986, + 904, 7599 ], "free_heap": [ - 8511875, + 8490699, 8521567 ], "max_alloc_block": [ - 102400, + 77824, 114688 ], "at": [ "2026-06-22", - "2026-06-22" + "2026-07-24" ] } } diff --git a/test/scenarios/light/scenario_Layers_composition.json b/test/scenarios/light/scenario_Layers_composition.json index 40201a57..aae11f8a 100644 --- a/test/scenarios/light/scenario_Layers_composition.json +++ b/test/scenarios/light/scenario_Layers_composition.json @@ -10,7 +10,7 @@ "Drivers", "NetworkSendDriver" ], - "description": "Multi-layer composition end-to-end: Layoutsβ†’Grid, TWO Layers under one Layers container (bottom Spiral, top Rainbow), Driversβ†’NetworkSendDriver. Proves the Drivers composite loop builds, allocates its output buffer, blends both enabled layers and feeds the result to the driver without crashing, and gates the bounded FPS so the N-pass composite cost is tracked. The exact alpha/additive blend math and the disable-drops-to-single-layer path are pinned by the unit tests (unit_BlendMap, unit_Layers_container); construct-mode set_control can't apply controls (built post-scheduler), so this scenario uses each Layer's default blend (alpha, full opacity) and asserts wired liveness + tick, not per-byte blend output.", + "description": "Multi-layer composition end-to-end: Layoutsβ†’Grid, TWO Layers under one Layers container (bottom Spiral, top Rainbow), Driversβ†’NetworkSendDriver. Proves the Drivers composite loop builds, allocates its output buffer, blends both enabled layers and feeds the result to the driver without crashing, and gates the bounded FPS so the N-pass composite cost is tracked. The exact alpha/additive blend math and the disable-drops-to-single-layer path are pinned by the unit tests (unit_BlendMap, unit_Layers_container); construct-mode set_control can't apply controls (built post-scheduler), so this scenario uses each Layer's default blend (additive, full opacity) and asserts wired liveness + tick, not per-byte blend output.", "steps": [ { "name": "add-layout-group", diff --git a/test/scenarios/light/scenario_Layouts_mutation.json b/test/scenarios/light/scenario_Layouts_mutation.json index 4988947f..e17a8390 100644 --- a/test/scenarios/light/scenario_Layouts_mutation.json +++ b/test/scenarios/light/scenario_Layouts_mutation.json @@ -133,19 +133,37 @@ "esp32": { "tick_us": [ 31, - 32 + 898 ], "free_heap": [ - 129644, + 97492, 132472 ], "max_alloc_block": [ - 110592, + 94208, 110592 ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 34, + 97732 + ], + "free_heap": [ + 7567331, + 8540587 + ], + "max_alloc_block": [ + 73728, + 86016 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -230,19 +248,37 @@ "esp32": { "tick_us": [ 36, - 42 + 2630 ], "free_heap": [ - 130400, + 79104, 132480 ], "max_alloc_block": [ - 110592, + 61440, 110592 ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 45, + 101203 + ], + "free_heap": [ + 7632239, + 8540371 + ], + "max_alloc_block": [ + 73728, + 86016 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -322,19 +358,37 @@ "esp32": { "tick_us": [ 34, - 37 + 1084 ], "free_heap": [ - 133264, + 97288, 133844 ], "max_alloc_block": [ - 110592, + 81920, 110592 ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 37, + 119764 + ], + "free_heap": [ + 7278475, + 8541447 + ], + "max_alloc_block": [ + 73728, + 86016 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } @@ -413,19 +467,37 @@ "esp32": { "tick_us": [ 33, - 36 + 928 ], "free_heap": [ - 130688, + 98528, 133328 ], "max_alloc_block": [ - 110592, + 94208, 110592 ], "at": [ "2026-07-22", - "2026-07-22" + "2026-07-24" + ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 34, + 119252 + ], + "free_heap": [ + 7578987, + 8542087 + ], + "max_alloc_block": [ + 86016, + 90112 + ], + "at": [ + "2026-07-24", + "2026-07-24" ] } } diff --git a/test/scenarios/light/scenario_MoonLiveEffect_controls.json b/test/scenarios/light/scenario_MoonLiveEffect_controls.json index e2b5079b..71f1b0cb 100644 --- a/test/scenarios/light/scenario_MoonLiveEffect_controls.json +++ b/test/scenarios/light/scenario_MoonLiveEffect_controls.json @@ -2,7 +2,10 @@ "name": "scenario_MoonLiveEffect_controls", "module": "MoonLiveEffect", "mode": "mutate", - "skip_on": ["desktop-windows", "desktop-linux"], + "skip_on": [ + "desktop-windows", + "desktop-linux" + ], "also": [ "Layouts", "GridLayout", @@ -74,6 +77,42 @@ "2026-06-28", "2026-07-01" ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 4624, + 97775 + ], + "free_heap": [ + 7552923, + 8513339 + ], + "max_alloc_block": [ + 61440, + 90112 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32": { + "tick_us": [ + 942, + 942 + ], + "free_heap": [ + 97028, + 97028 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -103,6 +142,42 @@ "2026-06-28", "2026-07-01" ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 3816, + 106739 + ], + "free_heap": [ + 7632131, + 8512955 + ], + "max_alloc_block": [ + 73728, + 90112 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32": { + "tick_us": [ + 963, + 963 + ], + "free_heap": [ + 97244, + 97244 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -132,6 +207,42 @@ "2026-06-28", "2026-07-01" ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 3816, + 110151 + ], + "free_heap": [ + 7553111, + 8512319 + ], + "max_alloc_block": [ + 61440, + 90112 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32": { + "tick_us": [ + 1138, + 1138 + ], + "free_heap": [ + 97456, + 97456 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -161,6 +272,42 @@ "2026-06-28", "2026-07-01" ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 4356, + 97693 + ], + "free_heap": [ + 7577019, + 8511991 + ], + "max_alloc_block": [ + 86016, + 86016 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32": { + "tick_us": [ + 934, + 934 + ], + "free_heap": [ + 97252, + 97252 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -190,6 +337,42 @@ "2026-06-28", "2026-07-01" ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 3823, + 112482 + ], + "free_heap": [ + 7559547, + 8511695 + ], + "max_alloc_block": [ + 61440, + 86016 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32": { + "tick_us": [ + 957, + 957 + ], + "free_heap": [ + 97460, + 97460 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -219,6 +402,42 @@ "2026-06-28", "2026-07-01" ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 3823, + 99446 + ], + "free_heap": [ + 7577251, + 8511743 + ], + "max_alloc_block": [ + 73728, + 86016 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32": { + "tick_us": [ + 901, + 901 + ], + "free_heap": [ + 97728, + 97728 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -248,6 +467,42 @@ "2026-06-28", "2026-07-01" ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 4281, + 102335 + ], + "free_heap": [ + 7563027, + 8511963 + ], + "max_alloc_block": [ + 65536, + 86016 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32": { + "tick_us": [ + 1245, + 1245 + ], + "free_heap": [ + 97440, + 97440 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -283,6 +538,42 @@ "2026-06-28", "2026-07-01" ] + }, + "esp32s3-n16r8": { + "tick_us": [ + 3817, + 105429 + ], + "free_heap": [ + 7618387, + 8511743 + ], + "max_alloc_block": [ + 61440, + 86016 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32": { + "tick_us": [ + 957, + 957 + ], + "free_heap": [ + 97240, + 97240 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } } diff --git a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json index daaa12df..97607de6 100644 --- a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json +++ b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json @@ -100,19 +100,19 @@ "esp32s3-n16r8": { "tick_us": [ 4013, - 4013 + 91265 ], "free_heap": [ - 8541659, + 7626803, 8541659 ], "max_alloc_block": [ - 106496, + 63488, 106496 ], "at": [ "2026-06-27", - "2026-06-27" + "2026-07-24" ] }, "esp32p4-eth": { @@ -132,6 +132,24 @@ "2026-06-27", "2026-06-27" ] + }, + "esp32": { + "tick_us": [ + 945, + 945 + ], + "free_heap": [ + 97128, + 97128 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -165,19 +183,19 @@ "esp32s3-n16r8": { "tick_us": [ 4450, - 4450 + 93309 ], "free_heap": [ - 8541007, + 7644499, 8541007 ], "max_alloc_block": [ - 106496, + 86016, 106496 ], "at": [ "2026-06-27", - "2026-06-27" + "2026-07-24" ] }, "esp32p4-eth": { @@ -197,6 +215,24 @@ "2026-06-27", "2026-06-27" ] + }, + "esp32": { + "tick_us": [ + 1245, + 1245 + ], + "free_heap": [ + 97040, + 97040 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -230,19 +266,19 @@ "esp32s3-n16r8": { "tick_us": [ 4366, - 4366 + 117718 ], "free_heap": [ - 8540491, + 7646319, 8540491 ], "max_alloc_block": [ - 106496, + 86016, 106496 ], "at": [ "2026-06-27", - "2026-06-27" + "2026-07-24" ] }, "esp32p4-eth": { @@ -262,6 +298,24 @@ "2026-06-27", "2026-06-27" ] + }, + "esp32": { + "tick_us": [ + 1126, + 1126 + ], + "free_heap": [ + 97464, + 97464 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -295,19 +349,19 @@ "esp32s3-n16r8": { "tick_us": [ 4040, - 4040 + 99305 ], "free_heap": [ - 8540211, + 7644291, 8540211 ], "max_alloc_block": [ - 102400, + 86016, 102400 ], "at": [ "2026-06-27", - "2026-06-27" + "2026-07-24" ] }, "esp32p4-eth": { @@ -327,6 +381,24 @@ "2026-06-27", "2026-06-27" ] + }, + "esp32": { + "tick_us": [ + 1223, + 1223 + ], + "free_heap": [ + 97456, + 97456 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -360,19 +432,19 @@ "esp32s3-n16r8": { "tick_us": [ 1152, - 1152 + 3759 ], "free_heap": [ - 8541371, + 7716035, 8541371 ], "max_alloc_block": [ - 102400, + 86016, 102400 ], "at": [ "2026-06-27", - "2026-06-27" + "2026-07-24" ] }, "esp32p4-eth": { @@ -392,6 +464,24 @@ "2026-06-27", "2026-06-27" ] + }, + "esp32": { + "tick_us": [ + 2471, + 2471 + ], + "free_heap": [ + 115448, + 115448 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -425,19 +515,19 @@ "esp32s3-n16r8": { "tick_us": [ 7329, - 7329 + 53246 ], "free_heap": [ - 8532499, + 7695303, 8532499 ], "max_alloc_block": [ - 102400, + 81920, 102400 ], "at": [ "2026-06-27", - "2026-06-27" + "2026-07-24" ] }, "esp32p4-eth": { @@ -457,6 +547,24 @@ "2026-06-27", "2026-06-27" ] + }, + "esp32": { + "tick_us": [ + 506, + 506 + ], + "free_heap": [ + 115840, + 115840 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -488,19 +596,19 @@ "esp32s3-n16r8": { "tick_us": [ 7387, - 7387 + 45224 ], "free_heap": [ - 8532839, + 7696391, 8532839 ], "max_alloc_block": [ - 102400, + 81920, 102400 ], "at": [ "2026-06-27", - "2026-06-27" + "2026-07-24" ] }, "esp32p4-eth": { @@ -520,6 +628,24 @@ "2026-06-27", "2026-06-27" ] + }, + "esp32": { + "tick_us": [ + 436, + 436 + ], + "free_heap": [ + 116904, + 116904 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } }, @@ -553,19 +679,19 @@ "esp32s3-n16r8": { "tick_us": [ 8255, - 8255 + 45747 ], "free_heap": [ - 8532255, + 7701291, 8532255 ], "max_alloc_block": [ - 102400, + 86016, 102400 ], "at": [ "2026-06-27", - "2026-06-27" + "2026-07-24" ] }, "esp32p4-eth": { @@ -585,6 +711,24 @@ "2026-06-27", "2026-06-27" ] + }, + "esp32": { + "tick_us": [ + 540, + 540 + ], + "free_heap": [ + 115836, + 115836 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] } } } diff --git a/test/scenarios/light/scenario_modifier_swap.json b/test/scenarios/light/scenario_modifier_swap.json index d274835c..025c235d 100644 --- a/test/scenarios/light/scenario_modifier_swap.json +++ b/test/scenarios/light/scenario_modifier_swap.json @@ -205,38 +205,38 @@ }, "esp32s3-n16r8": { "tick_us": [ - 389, - 564 + 76, + 14043 ], "free_heap": [ - 8550715, + 7713383, 8550879 ], "max_alloc_block": [ - 94208, + 77824, 94208 ], "at": [ "2026-06-25", - "2026-06-25" + "2026-07-24" ] }, "esp32": { "tick_us": [ 233, - 561 + 9755 ], "free_heap": [ - 122068, + 95424, 148900 ], "max_alloc_block": [ - 110592, + 69632, 110592 ], "at": [ "2026-06-25", - "2026-07-22" + "2026-07-24" ] }, "desktop-windows": { @@ -327,38 +327,38 @@ }, "esp32s3-n16r8": { "tick_us": [ - 1060, - 1082 + 354, + 15322 ], "free_heap": [ - 8549471, + 7714239, 8549607 ], "max_alloc_block": [ - 94208, + 73728, 94208 ], "at": [ "2026-06-25", - "2026-06-25" + "2026-07-24" ] }, "esp32": { "tick_us": [ 490, - 1121 + 10120 ], "free_heap": [ - 121348, + 95148, 148064 ], "max_alloc_block": [ - 110592, + 69632, 110592 ], "at": [ "2026-06-25", - "2026-07-22" + "2026-07-24" ] }, "desktop-windows": { @@ -449,38 +449,38 @@ }, "esp32s3-n16r8": { "tick_us": [ - 384, - 466 + 147, + 14660 ], "free_heap": [ - 8549087, + 7714659, 8550715 ], "max_alloc_block": [ - 94208, + 77824, 94208 ], "at": [ "2026-06-25", - "2026-06-25" + "2026-07-24" ] }, "esp32": { "tick_us": [ 230, - 481 + 8656 ], "free_heap": [ - 119368, + 97132, 148916 ], "max_alloc_block": [ - 110592, + 69632, 110592 ], "at": [ "2026-06-25", - "2026-07-22" + "2026-07-24" ] }, "desktop-windows": { diff --git a/test/scenarios/light/scenario_perf_full.json b/test/scenarios/light/scenario_perf_full.json index 7a18a8a3..b4db61b9 100644 --- a/test/scenarios/light/scenario_perf_full.json +++ b/test/scenarios/light/scenario_perf_full.json @@ -9,8 +9,7 @@ "PreviewDriver", "NetworkSendDriver", "RmtLedDriver", - "MultiPinLedDriver", - "ParlioLedDriver", + "ParallelLedDriver", "MultiplyModifier", "SpiralEffect", "NoiseEffect" @@ -105,37 +104,37 @@ "esp32s3-n16r8": { "tick_us": [ 111, - 186 + 284 ], "free_heap": [ - 8540003, + 8522875, 8552543 ], "max_alloc_block": [ - 94208, + 81920, 114688 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "esp32": { "tick_us": [ - 112, + 48, 348 ], "free_heap": [ - 121772, + 110568, 150728 ], "max_alloc_block": [ - 102400, + 94208, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -212,19 +211,19 @@ "esp32s3-n16r8": { "tick_us": [ 101, - 124 + 179 ], "free_heap": [ - 8538439, + 8529471, 8552531 ], "max_alloc_block": [ - 94208, + 86016, 114688 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "esp32": { @@ -233,16 +232,16 @@ 277 ], "free_heap": [ - 122416, + 121072, 150304 ], "max_alloc_block": [ - 102400, + 94208, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -319,19 +318,19 @@ "esp32s3-n16r8": { "tick_us": [ 101, - 129 + 187 ], "free_heap": [ - 8536747, + 8530931, 8552527 ], "max_alloc_block": [ - 94208, + 86016, 114688 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "esp32": { @@ -340,16 +339,16 @@ 307 ], "free_heap": [ - 123224, + 121104, 149816 ], "max_alloc_block": [ - 102400, + 94208, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -423,38 +422,38 @@ }, "esp32s3-n16r8": { "tick_us": [ - 236, + 121, 293 ], "free_heap": [ - 8536423, + 8530579, 8550727 ], "max_alloc_block": [ - 94208, + 86016, 114688 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "esp32": { "tick_us": [ - 246, + 150, 359 ], "free_heap": [ - 122508, + 122208, 148920 ], "max_alloc_block": [ - 102400, + 94208, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -536,19 +535,19 @@ "esp32s3-n16r8": { "tick_us": [ 106, - 124 + 206 ], "free_heap": [ - 8535099, + 8530323, 8552527 ], "max_alloc_block": [ - 94208, + 86016, 114688 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "esp32": { @@ -557,16 +556,16 @@ 365 ], "free_heap": [ - 122656, + 122108, 150108 ], "max_alloc_block": [ - 102400, + 94208, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -641,10 +640,10 @@ "esp32s3-n16r8": { "tick_us": [ 124, - 155 + 199 ], "free_heap": [ - 8533659, + 8530559, 8551087 ], "max_alloc_block": [ @@ -653,7 +652,7 @@ ], "at": [ "2026-06-17", - "2026-06-26" + "2026-07-24" ] }, "esp32": { @@ -662,16 +661,16 @@ 398 ], "free_heap": [ - 119812, + 119616, 148440 ], "max_alloc_block": [ - 102400, + 94208, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -757,7 +756,7 @@ "esp32s3-n16r8": { "tick_us": [ 107, - 139 + 190 ], "free_heap": [ 8506847, @@ -769,7 +768,7 @@ ], "at": [ "2026-06-17", - "2026-06-26" + "2026-07-24" ] }, "esp32": { @@ -782,12 +781,12 @@ 124952 ], "max_alloc_block": [ - 77824, + 69632, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -836,16 +835,19 @@ }, { "name": "add-i80-driver", - "description": "+MultiPinLedDriver capped to 64 LEDs on lane 0 (i80 needs all 8 data pins; unused lanes get 0 LEDs). S3 ONLY β€” the pins below are the S3's clean set. MultiPinLedDriver also registers on the classic ESP32 (I2S backend) and the P4 (LCD_CAM), but NO 8-pin set is safe on all three (only GPIO 21 is common: the classic's 6-11 are the SPI flash bus, the P4 exposes a different range), so one shared step cannot measure them. On a non-S3 target the driver fails to init and the measure below is meaningless β€” the observed blocks are therefore S3/desktop only. Diff = the i80 per-frame cost.", + "description": "+ParallelLedDriver (peripheral=i80) capped to 64 LEDs on lane 0 (i80 needs all 8 data pins; unused lanes get 0 LEDs). S3 ONLY β€” the pins below are the S3's clean set. The i80 peripheral also runs on the classic ESP32 (I2S backend) and the P4 (LCD_CAM), but NO 8-pin set is safe on all three (only GPIO 21 is common: the classic's 6-11 are the SPI flash bus, the P4 exposes a different range), so one shared step cannot measure them. On a non-S3 target the driver fails to init and the measure below is meaningless β€” the observed blocks are therefore S3/desktop only. Diff = the i80 per-frame cost.", "op": "add_module", "id": "MultiPin", - "type": "MultiPinLedDriver", + "type": "ParallelLedDriver", "parent_id": "Drivers", "props": { "pins": "1,2,3,4,5,6,7,8", "ledsPerPin": "64,0,0,0,0,0,0,0" }, - "optional": true + "optional": true, + "controls": { + "peripheral": "i80" + } }, { "name": "measure-i80", @@ -874,19 +876,19 @@ "esp32s3-n16r8": { "tick_us": [ 108, - 142 + 205 ], "free_heap": [ - 8533127, + 8530055, 8551951 ], "max_alloc_block": [ - 90112, + 86016, 114688 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "desktop-windows": { @@ -906,24 +908,6 @@ "2026-07-08", "2026-07-08" ] - }, - "esp32": { - "tick_us": [ - 310, - 311 - ], - "free_heap": [ - 119224, - 121220 - ], - "max_alloc_block": [ - 102400, - 110592 - ], - "at": [ - "2026-07-22", - "2026-07-22" - ] } }, "description": "The i80 per-frame cost. Only meaningful where add-i80-driver's pins are valid (S3; desktop is inert but exercises the code path) β€” the classic/P4 blocks were removed: they recorded a driver that had failed to init, so the tiny tick was the bail-out path, not an encode." @@ -936,16 +920,19 @@ }, { "name": "add-parlio-driver", - "description": "+ParlioLedDriver capped to 64 LEDs on lane 0. Optional β€” P4 only. Diff = the Parlio per-frame cost.", + "description": "+ParallelLedDriver (peripheral=Parlio) capped to 64 LEDs on lane 0. Optional β€” P4 only. Diff = the Parlio per-frame cost.", "op": "add_module", "id": "Parlio", - "type": "ParlioLedDriver", + "type": "ParallelLedDriver", "parent_id": "Drivers", "props": { "pins": "20", "ledsPerPin": "64" }, - "optional": true + "optional": true, + "controls": { + "peripheral": "Parlio" + } }, { "name": "measure-parlio", @@ -971,42 +958,6 @@ "2026-07-01" ] }, - "esp32s3-n16r8": { - "tick_us": [ - 106, - 130 - ], - "free_heap": [ - 8537691, - 8552603 - ], - "max_alloc_block": [ - 94208, - 114688 - ], - "at": [ - "2026-06-17", - "2026-06-25" - ] - }, - "esp32": { - "tick_us": [ - 101, - 313 - ], - "free_heap": [ - 121944, - 150316 - ], - "max_alloc_block": [ - 102400, - 110592 - ], - "at": [ - "2026-06-17", - "2026-07-22" - ] - }, "esp32p4-eth": { "tick_us": [ 56, @@ -1101,19 +1052,19 @@ "esp32s3-n16r8": { "tick_us": [ 101, - 119 + 222 ], "free_heap": [ - 8535703, + 8533215, 8552535 ], "max_alloc_block": [ - 94208, + 86016, 114688 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "esp32": { @@ -1126,12 +1077,12 @@ 150100 ], "max_alloc_block": [ - 102400, + 94208, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -1212,19 +1163,19 @@ "esp32s3-n16r8": { "tick_us": [ 278, - 328 + 625 ], "free_heap": [ - 8531251, + 8523159, 8550235 ], "max_alloc_block": [ - 94208, + 86016, 114688 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "esp32": { @@ -1237,12 +1188,12 @@ 147564 ], "max_alloc_block": [ - 102400, + 94208, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -1323,19 +1274,19 @@ "esp32s3-n16r8": { "tick_us": [ 989, - 1118 + 2268 ], "free_heap": [ - 8510995, + 8494743, 8541019 ], "max_alloc_block": [ - 90112, + 81920, 114688 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "esp32": { @@ -1348,12 +1299,12 @@ 138300 ], "max_alloc_block": [ - 86016, + 65536, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -1434,24 +1385,24 @@ "esp32s3-n16r8": { "tick_us": [ 7488, - 8799 + 11871 ], "free_heap": [ - 8490295, + 8482135, 8504331 ], "max_alloc_block": [ - 94208, + 81920, 114688 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "esp32": { "tick_us": [ - 2270, + 2113, 4458 ], "free_heap": [ @@ -1459,12 +1410,12 @@ 101728 ], "max_alloc_block": [ - 51200, + 24576, 63488 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -1552,25 +1503,25 @@ }, "esp32s3-n16r8": { "tick_us": [ - 735, + 288, 909 ], "free_heap": [ - 8541939, + 8528487, 8552519 ], "max_alloc_block": [ - 94208, + 86016, 114688 ], "at": [ "2026-06-17", - "2026-06-26" + "2026-07-24" ] }, "esp32": { "tick_us": [ - 370, + 319, 1010 ], "free_heap": [ @@ -1578,12 +1529,12 @@ 150120 ], "max_alloc_block": [ - 102400, + 94208, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -1663,25 +1614,25 @@ }, "esp32s3-n16r8": { "tick_us": [ - 2808, + 1028, 3447 ], "free_heap": [ - 8539631, + 8531111, 8550215 ], "max_alloc_block": [ - 94208, + 86016, 114688 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "esp32": { "tick_us": [ - 1280, + 570, 3263 ], "free_heap": [ @@ -1689,12 +1640,12 @@ 147592 ], "max_alloc_block": [ - 102400, + 94208, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -1774,25 +1725,25 @@ }, "esp32s3-n16r8": { "tick_us": [ - 11073, + 3989, 11740 ], "free_heap": [ - 8530415, + 8519555, 8541023 ], "max_alloc_block": [ - 94208, + 86016, 114688 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "esp32": { "tick_us": [ - 4911, + 3099, 13547 ], "free_heap": [ @@ -1800,12 +1751,12 @@ 138600 ], "max_alloc_block": [ - 81920, + 65536, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -1885,25 +1836,25 @@ }, "esp32s3-n16r8": { "tick_us": [ - 48051, + 16915, 51959 ], "free_heap": [ - 8491607, + 8471071, 8504171 ], "max_alloc_block": [ - 94208, + 77824, 114688 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "esp32": { "tick_us": [ - 7307, + 4569, 62316 ], "free_heap": [ @@ -1911,12 +1862,12 @@ 101444 ], "max_alloc_block": [ - 51200, + 24576, 63488 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -1986,20 +1937,20 @@ "observed": { "esp32s3-n16r8": { "tick_us": [ - 382, + 147, 456 ], "free_heap": [ - 8540231, + 8528155, 8550719 ], "max_alloc_block": [ - 94208, + 86016, 114688 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "desktop-macos": { @@ -2022,7 +1973,7 @@ }, "esp32": { "tick_us": [ - 247, + 186, 495 ], "free_heap": [ @@ -2030,12 +1981,12 @@ 148920 ], "max_alloc_block": [ - 102400, + 94208, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -2097,20 +2048,20 @@ "observed": { "esp32s3-n16r8": { "tick_us": [ - 1409, + 459, 1668 ], "free_heap": [ - 8528551, + 8524727, 8544039 ], "max_alloc_block": [ - 94208, + 86016, 114688 ], "at": [ "2026-06-17", - "2026-06-26" + "2026-07-24" ] }, "desktop-macos": { @@ -2133,7 +2084,7 @@ }, "esp32": { "tick_us": [ - 652, + 263, 1827 ], "free_heap": [ @@ -2141,12 +2092,12 @@ 143852 ], "max_alloc_block": [ - 98304, + 94208, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -2208,20 +2159,20 @@ "observed": { "esp32s3-n16r8": { "tick_us": [ - 6165, + 2474, 8424 ], "free_heap": [ - 8506427, + 8496435, 8516999 ], "max_alloc_block": [ - 94208, + 86016, 114688 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "desktop-macos": { @@ -2244,7 +2195,7 @@ }, "esp32": { "tick_us": [ - 2402, + 1360, 6958 ], "free_heap": [ @@ -2252,12 +2203,12 @@ 124804 ], "max_alloc_block": [ - 73728, + 65536, 102400 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -2319,20 +2270,20 @@ "observed": { "esp32s3-n16r8": { "tick_us": [ - 28061, + 14852, 59546 ], "free_heap": [ - 8398527, + 8376071, 8409091 ], "max_alloc_block": [ - 94208, + 77824, 114688 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "desktop-macos": { @@ -2355,12 +2306,12 @@ }, "esp32": { "tick_us": [ - 5222, + 3089, 33544 ], "free_heap": [ 36648, - 64348 + 84296 ], "max_alloc_block": [ 24576, @@ -2368,7 +2319,7 @@ ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { diff --git a/test/scenarios/light/scenario_perf_light.json b/test/scenarios/light/scenario_perf_light.json index c15345b4..9a30396f 100644 --- a/test/scenarios/light/scenario_perf_light.json +++ b/test/scenarios/light/scenario_perf_light.json @@ -120,7 +120,7 @@ "esp32s3-n16r8": { "tick_us": [ 113, - 172 + 264 ], "free_heap": [ 8515895, @@ -132,7 +132,7 @@ ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "esp32": { @@ -145,12 +145,12 @@ 150456 ], "max_alloc_block": [ - 102400, + 94208, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -225,7 +225,7 @@ }, "esp32s3-n16r8": { "tick_us": [ - 248, + 120, 313 ], "free_heap": [ @@ -233,12 +233,12 @@ 8545279 ], "max_alloc_block": [ - 94208, + 86016, 102400 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "esp32": { @@ -251,12 +251,12 @@ 150316 ], "max_alloc_block": [ - 102400, + 94208, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -323,11 +323,11 @@ }, "esp32s3-n16r8": { "tick_us": [ - 234, + 116, 263 ], "free_heap": [ - 8529571, + 8527727, 8545271 ], "max_alloc_block": [ @@ -336,7 +336,7 @@ ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "esp32": { @@ -349,12 +349,12 @@ 149812 ], "max_alloc_block": [ - 102400, + 94208, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -428,25 +428,25 @@ }, "esp32s3-n16r8": { "tick_us": [ - 399, + 147, 435 ], "free_heap": [ - 8532403, + 8531663, 8542143 ], "max_alloc_block": [ - 90112, + 86016, 102400 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "esp32": { "tick_us": [ - 239, + 210, 876 ], "free_heap": [ @@ -454,12 +454,12 @@ 149804 ], "max_alloc_block": [ - 102400, + 94208, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -539,11 +539,11 @@ }, "esp32s3-n16r8": { "tick_us": [ - 1399, + 458, 1778 ], "free_heap": [ - 8528343, + 8518903, 8533407 ], "max_alloc_block": [ @@ -552,12 +552,12 @@ ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "esp32": { "tick_us": [ - 620, + 531, 3774 ], "free_heap": [ @@ -565,12 +565,12 @@ 147500 ], "max_alloc_block": [ - 102400, + 94208, 110592 ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { @@ -650,25 +650,25 @@ }, "esp32s3-n16r8": { "tick_us": [ - 6184, + 2485, 7729 ], "free_heap": [ - 8501355, + 8475579, 8517015 ], "max_alloc_block": [ - 94208, + 65536, 110592 ], "at": [ "2026-06-17", - "2026-06-25" + "2026-07-24" ] }, "esp32": { "tick_us": [ - 2181, + 1958, 12962 ], "free_heap": [ @@ -681,7 +681,7 @@ ], "at": [ "2026-06-17", - "2026-07-22" + "2026-07-24" ] }, "esp32p4-eth": { diff --git a/test/scenarios/light/scenario_peripheral_switch.json b/test/scenarios/light/scenario_peripheral_switch.json new file mode 100644 index 00000000..744c3778 --- /dev/null +++ b/test/scenarios/light/scenario_peripheral_switch.json @@ -0,0 +1,704 @@ +{ + "name": "scenario_peripheral_switch", + "module": "ParallelLedDriver", + "mode": "mutate", + "also": [ + "MultiPinLedDriver", + "MoonLedDriver", + "ParlioLedDriver", + "Layouts", + "GridLayout", + "Layers", + "Layer", + "NoiseEffect", + "Drivers", + "PreviewDriver" + ], + "description": "Switch a live ParallelLedDriver between its DMA peripherals (i80 / MoonI80 / Parlio) and verify the device keeps running clean across every swap, and that the peripheral-dependent controls (doubleBuffer, pinExpander) behave per backend. Builds one Layout(Grid 16x16) + one Layer + one NoiseEffect + one ParallelLedDriver on lane-0 (64 LEDs), then set_control cycles `peripheral` and toggles `doubleBuffer`/`pinExpander`, measuring after each. The peripheral steps are `optional` so a board that lacks a given peripheral (a classic ESP32 offers only i80/I2S; a P4 adds Parlio; an S3 offers i80+MoonI80) skips them without failing. Guards the runtime-peripheral-strategy consolidation (ADR-0016): a swap that crashes, wedges, or leaves the driver in a bad state shows up here. Specifically pins the MoonI80 double-buffer fix: with doubleBuffer ON, MoonI80 must run single-buffer and NOT freeze (its own-GDMA whole-frame two-buffer handshake races); the measure after switching to MoonI80 with doubleBuffer ON is the regression guard (a freeze shows as a ~200 ms tick).", + "fixture": [ + { + "name": "fix-layouts", + "op": "add_module", + "id": "Layouts", + "type": "Layouts" + }, + { + "name": "fix-grid", + "op": "add_module", + "id": "Grid", + "type": "GridLayout", + "parent_id": "Layouts", + "props": { + "width": 16, + "height": 16 + } + }, + { + "name": "fix-layers", + "op": "add_module", + "id": "Layers", + "type": "Layers", + "props": { + "layouts": "Layouts" + } + }, + { + "name": "fix-layer", + "op": "add_module", + "id": "Layer", + "type": "Layer", + "parent_id": "Layers", + "props": { + "channelsPerLight": 3 + } + }, + { + "name": "fix-fx", + "op": "add_module", + "id": "FX", + "type": "NoiseEffect", + "parent_id": "Layer" + }, + { + "name": "fix-drivers", + "op": "add_module", + "id": "Drivers", + "type": "Drivers", + "props": { + "layers": "Layers" + } + } + ], + "steps": [ + { + "name": "shrink-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16, + "optional": true + }, + { + "name": "shrink-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "optional": true + }, + { + "name": "clear-layers", + "description": "Start clean: drop whatever effects/modifiers the device had.", + "op": "clear_children", + "id": "Layers" + }, + { + "name": "clear-drivers", + "description": "Drop existing drivers (the pre-wired Preview survives on a device).", + "op": "clear_children", + "id": "Drivers" + }, + { + "name": "rebuild-layer", + "op": "add_module", + "id": "Layer", + "type": "Layer", + "parent_id": "Layers", + "props": { + "channelsPerLight": 3 + } + }, + { + "name": "rebuild-fx", + "op": "add_module", + "id": "FX", + "type": "NoiseEffect", + "parent_id": "Layer" + }, + { + "name": "add-parallel-driver", + "description": "One ParallelLedDriver on lane 0, 64 LEDs (i80 rounds the bus up to 8 pins and parks the spare lanes; the 8-pin set is the S3's clean range). The peripheral is set per switch step below.", + "op": "add_module", + "id": "ParallelLed", + "type": "ParallelLedDriver", + "parent_id": "Drivers", + "props": { + "pins": "1,2,3,4,5,6,7,8", + "ledsPerPin": "64,0,0,0,0,0,0,0" + } + }, + { + "name": "switch-i80", + "description": "Select the i80 peripheral (esp_lcd: LCD_CAM on S3/P4/S31, I2S on classic).", + "op": "set_control", + "id": "ParallelLed", + "key": "peripheral", + "value": "i80", + "optional": true + }, + { + "name": "measure-i80-single", + "description": "i80 single-buffer baseline. Only meaningful where the pins are valid (S3/desktop path); a classic/P4 records its own i80 backend.", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s3-n16r8": { + "tick_us": [ + 42, + 8191 + ], + "free_heap": [ + 8490863, + 8539759 + ], + "max_alloc_block": [ + 81920, + 106496 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "desktop-macos": { + "tick_us": [ + 4, + 6 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 208, + 208 + ], + "free_heap": [ + 33961671, + 33961671 + ], + "max_alloc_block": [ + 352256, + 352256 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32": { + "tick_us": [ + 389, + 389 + ], + "free_heap": [ + 120332, + 120332 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + } + } + }, + { + "name": "i80-doublebuffer-on", + "description": "i80 supports the async double-buffer (esp_lcd has a real transaction queue) β€” turn it on.", + "op": "set_control", + "id": "ParallelLed", + "key": "doubleBuffer", + "value": true, + "optional": true + }, + { + "name": "measure-i80-double", + "description": "i80 with double-buffer: the encode overlaps the wire, so the tick should be at or below the single-buffer baseline. No freeze.", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s3-n16r8": { + "tick_us": [ + 46, + 8925 + ], + "free_heap": [ + 8413619, + 8539115 + ], + "max_alloc_block": [ + 81920, + 106496 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "desktop-macos": { + "tick_us": [ + 4, + 6 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 215, + 215 + ], + "free_heap": [ + 33961679, + 33961679 + ], + "max_alloc_block": [ + 352256, + 352256 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32": { + "tick_us": [ + 316, + 316 + ], + "free_heap": [ + 120120, + 120120 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + } + } + }, + { + "name": "switch-moonI80-dbstill-on", + "description": "Switch to MoonI80 with doubleBuffer STILL ON. This is the regression guard: MoonI80 must ignore the async request and run single-buffer (its whole-frame own-GDMA two-buffer handshake races and wedges). The measure below must be a normal tick, NOT ~200 ms.", + "op": "set_control", + "id": "ParallelLed", + "key": "peripheral", + "value": "MoonI80", + "optional": true + }, + { + "name": "measure-moonI80-nofreeze", + "description": "MoonI80 whole-frame, doubleBuffer requested but forced single. A ~200 ms tick here is the double-buffer freeze regressing; a normal tick is the fix holding.", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s3-n16r8": { + "tick_us": [ + 42, + 8960 + ], + "free_heap": [ + 8497787, + 8538287 + ], + "max_alloc_block": [ + 81920, + 114688 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "desktop-macos": { + "tick_us": [ + 4, + 6 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 216, + 216 + ], + "free_heap": [ + 33969675, + 33969675 + ], + "max_alloc_block": [ + 360448, + 360448 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32": { + "tick_us": [ + 316, + 316 + ], + "free_heap": [ + 122032, + 122032 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + } + } + }, + { + "name": "moonI80-doublebuffer-off", + "description": "Explicitly turn doubleBuffer off on MoonI80 (a no-op behaviorally β€” it was already forced single β€” but exercises the control write on the hidden control).", + "op": "set_control", + "id": "ParallelLed", + "key": "doubleBuffer", + "value": false, + "optional": true + }, + { + "name": "measure-moonI80-single", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s3-n16r8": { + "tick_us": [ + 44, + 8708 + ], + "free_heap": [ + 8490871, + 8538079 + ], + "max_alloc_block": [ + 81920, + 106496 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "desktop-macos": { + "tick_us": [ + 4, + 6 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 209, + 209 + ], + "free_heap": [ + 33961491, + 33961491 + ], + "max_alloc_block": [ + 352256, + 352256 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32": { + "tick_us": [ + 343, + 343 + ], + "free_heap": [ + 122052, + 122052 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + } + } + }, + { + "name": "moonI80-expander-on", + "description": "Turn on the 74HCT595 pin expander: MoonI80 switches to the streaming RING path (wantsRing()). At 64 LOGICAL lights the ring still initializes; this exercises the ring engage on a live switch without needing a 48x256 rig.", + "op": "set_control", + "id": "ParallelLed", + "key": "pinExpander", + "value": true, + "optional": true + }, + { + "name": "measure-moonI80-ring", + "description": "MoonI80 ring path live. A crash/wedge on the expander engage shows here.", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s3-n16r8": { + "tick_us": [ + 44, + 9233 + ], + "free_heap": [ + 8489355, + 8538291 + ], + "max_alloc_block": [ + 81920, + 106496 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "desktop-macos": { + "tick_us": [ + 4, + 6 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 217, + 217 + ], + "free_heap": [ + 33961487, + 33961487 + ], + "max_alloc_block": [ + 352256, + 352256 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32": { + "tick_us": [ + 326, + 326 + ], + "free_heap": [ + 121824, + 121824 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + } + } + }, + { + "name": "moonI80-expander-off", + "op": "set_control", + "id": "ParallelLed", + "key": "pinExpander", + "value": false, + "optional": true + }, + { + "name": "switch-parlio", + "description": "Select Parlio (P4 only). On a board that doesn't offer it (S3/classic) the Select value is out of range β†’ the device returns 400 and this optional step is SKIPPED, leaving the peripheral where it was. No standalone measure follows it (that would record the wrong peripheral on a skip); the round-trip measure below covers the post-switch state. On a P4 the switch succeeds and the round-trip back to i80 exercises the Parlioβ†’i80 swap.", + "op": "set_control", + "id": "ParallelLed", + "key": "peripheral", + "value": "Parlio", + "optional": true + }, + { + "name": "switch-back-i80", + "description": "Round-trip back to i80 β€” the driver must return to a clean, running state after the whole cycle.", + "op": "set_control", + "id": "ParallelLed", + "key": "peripheral", + "value": "i80", + "optional": true + }, + { + "name": "measure-back-i80", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s3-n16r8": { + "tick_us": [ + 42, + 10004 + ], + "free_heap": [ + 8477667, + 8538251 + ], + "max_alloc_block": [ + 81920, + 114688 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "desktop-macos": { + "tick_us": [ + 4, + 6 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 206, + 206 + ], + "free_heap": [ + 33970039, + 33970039 + ], + "max_alloc_block": [ + 360448, + 360448 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + }, + "esp32": { + "tick_us": [ + 320, + 320 + ], + "free_heap": [ + 121604, + 121604 + ], + "max_alloc_block": [ + 94208, + 94208 + ], + "at": [ + "2026-07-24", + "2026-07-24" + ] + } + } + }, + { + "name": "cleanup-driver", + "op": "remove_module", + "id": "ParallelLed", + "optional": true + }, + { + "name": "cleanup-fx", + "op": "remove_module", + "id": "FX", + "optional": true + }, + { + "name": "cleanup-layer", + "op": "remove_module", + "id": "Layer", + "optional": true + } + ] +} diff --git a/test/unit/core/unit_AudioService_sync.cpp b/test/unit/core/unit_AudioService_sync.cpp index 95fb476e..baff6334 100644 --- a/test/unit/core/unit_AudioService_sync.cpp +++ b/test/unit/core/unit_AudioService_sync.cpp @@ -41,6 +41,32 @@ struct FrozenClock { }; } // namespace +// Regression: the mic status ("mic: set sckPin / wsPin / sdPin", and the other mic diagnostics) is a +// LOCAL-mode read-out. Switching to Receive network / Simulate must clear it so a stale mic message +// doesn't linger on the status row β€” those modes report through the separate "sync status" row and have +// no mic to diagnose. Before the fix, prepare()'s non-Local branch deinit()'d the peripheral but left +// the status string set from the prior Local build (or from boot with pins unset). +TEST_CASE("AudioService: switching out of Local mode clears the mic status") { + AudioService a; + a.mode = 0; // Local audio, pins unset (the default) β†’ mic status set on build + a.applyState(); + // On a host build hasI2sMic is false, so reinit() sets "mic: no I2S on this platform"; on a device + // with unset pins it's "mic: set sckPin / wsPin / sdPin". Either way Local mode leaves a mic status. + CHECK(a.status() != nullptr); + CHECK(std::strstr(a.status(), "mic") != nullptr); + + a.mode = 1; // receive network + a.applyState(); // prepare() non-Local branch must clear the stale mic status + CHECK((a.status() == nullptr || a.status()[0] == 0)); // no lingering mic message on the status row + + // And back to Simulate β€” same rule (no mic there either). + a.mode = AudioService::kSimMode; + a.applyState(); + CHECK((a.status() == nullptr || a.status()[0] == 0)); + + a.release(); +} + TEST_CASE("AudioService Local+send: lazy-opens once and reports sending") { FrozenClock clk(1); AudioService a; diff --git a/test/unit/core/unit_Control_apply_absent_key.cpp b/test/unit/core/unit_Control_apply_absent_key.cpp index 9ee9bf05..7310d1b3 100644 --- a/test/unit/core/unit_Control_apply_absent_key.cpp +++ b/test/unit/core/unit_Control_apply_absent_key.cpp @@ -19,9 +19,11 @@ #include "doctest.h" #include "core/Control.h" #include "core/JsonUtil.h" +#include "core/JsonSink.h" // PaletteOptionsFn's JsonSink parameter (the palette-crash regression) #include #include +#include // std::string β€” the overlong-label regression builds its JSON // hasKey distinguishes an absent key from one whose value is 0 β€” the capability the // fix relies on. parseInt alone can't (returns 0 for both). @@ -169,3 +171,137 @@ TEST_CASE("a Text control with no validator accepts anything that fits") { "label", mm::ApplyPolicy::Clamp) == mm::ApplyResult::Ok); CHECK(std::strcmp(label, "hi") == 0); } + +// A Palette control's aux holds a PaletteOptionsFn (a FUNCTION POINTER), not an options array. The +// Select label-match path must therefore NOT run for Palette: reinterpreting a function pointer as a +// char* const* and walking it dereferences code bytes β€” undefined behavior, a near-certain crash on +// ESP32. The regression: a string value on a palette must fall to numeric-index apply (parseInt β†’ 0), +// exactly the harmless behavior before the label-match feature existed. (Robust to any input.) +static void paletteOptions(mm::JsonSink& sink) { + sink.append("[\"Rainbow\",\"Ocean\",\"Forest\"]"); // a real fn body; never read via the aux cast +} +TEST_CASE("applyControlValue: a string palette value does not crash and applies numerically") { + mm::ControlList controls; + uint8_t palette = 1; + controls.addPalette("palette", palette, paletteOptions, 3); + + // A STRING value (as a hand-edited config or a mistaken client could send). Before the fix this + // walked the function pointer as an options array. After: string β†’ parseInt β†’ 0, clamped in range. + CHECK(mm::applyControlValue(controls[0], "{\"palette\":\"Rainbow\"}", "palette", + mm::ApplyPolicy::Clamp) == mm::ApplyResult::Ok); + CHECK(palette == 0); // numeric fallback, no function-pointer deref + + // A numeric value still applies straight through. + CHECK(mm::applyControlValue(controls[0], "{\"palette\":2}", "palette", + mm::ApplyPolicy::Clamp) == mm::ApplyResult::Ok); + CHECK(palette == 2); +} + +// The complement: a Select's aux IS the options array, so a string LABEL value matches an option by +// name (the board-portable catalog path β€” a peripheral label is stable while its filtered index is +// not). This keeps the label-match feature working where it is safe. +TEST_CASE("applyControlValue: a Select accepts an option label as a string value") { + mm::ControlList controls; + uint8_t sel = 0; + static const char* const opts[] = {"i80", "MoonI80", "Parlio"}; + controls.addSelect("peripheral", sel, opts, 3); + + CHECK(mm::applyControlValue(controls[0], "{\"peripheral\":\"Parlio\"}", "peripheral", + mm::ApplyPolicy::Clamp) == mm::ApplyResult::Ok); + CHECK(sel == 2); // matched the "Parlio" label β†’ index 2 + + // A numeric index still works alongside the label path. + CHECK(mm::applyControlValue(controls[0], "{\"peripheral\":1}", "peripheral", + mm::ApplyPolicy::Clamp) == mm::ApplyResult::Ok); + CHECK(sel == 1); +} + +// An empty option list (max == 0) has no valid index β€” applying a value must not manufacture index 0. +// Strict rejects; Lenient (Clamp) leaves the bound value untouched. Guards a board-filtered Select that +// filtered down to zero options (e.g. a peripheral list on a chip that supports none). +TEST_CASE("applyControlValue: an empty Select rejects/no-ops instead of accepting index 0") { + mm::ControlList controls; + uint8_t sel = 7; // a sentinel that a spurious index-0 write would clobber + static const char* const noOpts[] = {nullptr}; + controls.addSelect("peripheral", sel, noOpts, 0); // zero options + + CHECK(mm::applyControlValue(controls[0], "{\"peripheral\":0}", "peripheral", + mm::ApplyPolicy::Clamp) == mm::ApplyResult::Ok); + CHECK(sel == 7); // untouched β€” no index 0 manufactured + CHECK(mm::applyControlValue(controls[0], "{\"peripheral\":\"i80\"}", "peripheral", + mm::ApplyPolicy::Clamp) == mm::ApplyResult::Ok); + CHECK(sel == 7); // a label on an empty list is also a no-op + CHECK(mm::applyControlValue(controls[0], "{\"peripheral\":0}", "peripheral", + mm::ApplyPolicy::Strict) == mm::ApplyResult::OutOfRange); +} + +// The Palette twin of the empty-Select guard: an empty palette (addPalette(..., 0)) has no valid index, +// so a value must not write index 0 β€” and critically must not let `hi = c.max - 1` underflow to -1 and +// clamp the stored value up to 255. Lenient leaves the sentinel untouched; Strict returns OutOfRange. +TEST_CASE("applyControlValue: an empty Palette rejects/no-ops and never underflows to 255") { + mm::ControlList controls; + uint8_t pal = 7; // sentinel: a spurious index-0 OR a 255 underflow would clobber it + controls.addPalette("palette", pal, paletteOptions, 0); // zero options + + CHECK(mm::applyControlValue(controls[0], "{\"palette\":0}", "palette", + mm::ApplyPolicy::Clamp) == mm::ApplyResult::Ok); + CHECK(pal == 7); // untouched β€” neither index 0 nor a 255 underflow + CHECK(mm::applyControlValue(controls[0], "{\"palette\":3}", "palette", + mm::ApplyPolicy::Clamp) == mm::ApplyResult::Ok); + CHECK(pal == 7); // a representative palette value is also a no-op on an empty list + CHECK(pal != 255); // explicit: the c.max-1 underflow the guard prevents + CHECK(mm::applyControlValue(controls[0], "{\"palette\":0}", "palette", + mm::ApplyPolicy::Strict) == mm::ApplyResult::OutOfRange); +} + +// A label longer than any real option (here, longer than the parse buffer) must NOT match a real option +// by prefix β€” it is "no such option", so Lenient keeps the default and Strict rejects. Guards against a +// truncated value spuriously equalling a shorter option that shares its leading characters. +TEST_CASE("applyControlValue: an overlong Select label does not prefix-match a real option") { + mm::ControlList controls; + uint8_t sel = 0; + static const char* const opts[] = {"i80", "MoonI80", "Parlio"}; + controls.addSelect("peripheral", sel, opts, 3); + + // 80 'M' chars β€” far past any option and past the parse buffer; must not match "MoonI80" by prefix. + std::string longVal(80, 'M'); + std::string json = "{\"peripheral\":\"" + longVal + "\"}"; + CHECK(mm::applyControlValue(controls[0], json.c_str(), "peripheral", + mm::ApplyPolicy::Clamp) == mm::ApplyResult::Ok); + CHECK(sel == 0); // default kept, no spurious match + CHECK(mm::applyControlValue(controls[0], json.c_str(), "peripheral", + mm::ApplyPolicy::Strict) == mm::ApplyResult::OutOfRange); +} + +// The exact boundary of the overlong guard. The Select label parses into a 64-byte buffer and a value +// that FILLS it (length >= 63, i.e. buffer_size - 1) is treated as overlong β€” it may have been truncated +// to the cap, so it cannot legitimately equal any option and the match is skipped. A value one shorter +// (62) is NOT overlong and matches normally. This pins the threshold so a future buffer-size change +// can't silently shift where a legitimate long label starts being rejected. Real option labels sit far +// below this (the longest peripheral/mode label is ~35 chars), so the boundary only ever fences off +// junk β€” but the test makes that contract explicit rather than incidental. +TEST_CASE("applyControlValue: the Select overlong-label boundary is exactly the parse buffer") { + mm::ControlList controls; + uint8_t sel = 0; + // Two options at the boundary lengths: one 62 chars (just under the cap), one 63 (at the cap). + static const std::string at62(62, 'a'); + static const std::string at63(63, 'b'); + static const char* const opts[] = {"i80", at62.c_str(), at63.c_str()}; + controls.addSelect("peripheral", sel, opts, 3); + + // 62 chars: not overlong β†’ matches option index 1. + const std::string j62 = "{\"peripheral\":\"" + at62 + "\"}"; + CHECK(mm::applyControlValue(controls[0], j62.c_str(), "peripheral", + mm::ApplyPolicy::Clamp) == mm::ApplyResult::Ok); + CHECK(sel == 1); + + // 63 chars: fills the buffer β†’ treated as overlong, the match is skipped even though an option of + // that exact text EXISTS. Lenient keeps the current value; Strict rejects. + sel = 0; + const std::string j63 = "{\"peripheral\":\"" + at63 + "\"}"; + CHECK(mm::applyControlValue(controls[0], j63.c_str(), "peripheral", + mm::ApplyPolicy::Clamp) == mm::ApplyResult::Ok); + CHECK(sel == 0); // NOT matched to index 2 β€” the cap fences it off + CHECK(mm::applyControlValue(controls[0], j63.c_str(), "peripheral", + mm::ApplyPolicy::Strict) == mm::ApplyResult::OutOfRange); +} diff --git a/test/unit/core/unit_FilesystemModule_persistence.cpp b/test/unit/core/unit_FilesystemModule_persistence.cpp index 4fdd40a0..c8aa3e7e 100644 --- a/test/unit/core/unit_FilesystemModule_persistence.cpp +++ b/test/unit/core/unit_FilesystemModule_persistence.cpp @@ -473,3 +473,307 @@ TEST_CASE("FilesystemModule Int16 controls round-trip preserves the saved value" std::filesystem::remove_all(tmpRoot); mm::platform::fsSetRoot("."); } + +// Regression: a module whose CONTROL SET depends on one of its own control VALUES must restore its +// value-dependent controls across a reboot. The canonical case is ParallelLedDriver's `peripheral` +// Select, which swaps the bus backend and with it the backend-owned controls (clockPin, ring cluster). +// Without the fix, a single overlay writes the saved `clockPin` onto the DEFAULT backend's member, and +// the later swap to the saved peripheral discards it β€” clockPin (and the ring geometry) silently revert +// to their defaults on any reload that rebuilds the control set (a device reboot, or an INT_WDT +// restart). applyNode's overlay β†’ rebuildControls β†’ overlay-again fixes it: the second overlay lands on +// the now-correct backend member. This mock reproduces the structure minimally: `mode` picks which of +// two backing variables `param` binds to, so a naive single-overlay writes param to the wrong one. +namespace { +struct ModeDependentMock : public mm::MoonModule { + uint8_t mode = 0; // the "peripheral" analogue: selects the control set + uint8_t paramA = 10; // bound when mode==0 (the "default backend" member, default 10) + uint8_t paramB = 10; // bound when mode==1 (the "swapped backend" member, default 10) + static constexpr const char* kModes[2] = {"A", "B"}; + void defineControls() override { + controls_.addSelect("mode", mode, kModes, 2); + // `param` binds to a DIFFERENT variable depending on mode β€” exactly like clockPin binding to + // whichever peripheral backend is live. A rebuild after `mode` changes re-binds it. + controls_.addUint8("param", mode == 0 ? paramA : paramB, 0, 255); + } +}; +} // namespace + +TEST_CASE("FilesystemModule restores a value-dependent control across reload (the peripheral/clockPin bug)") { + char tmpRoot[256]; + std::snprintf(tmpRoot, sizeof(tmpRoot), "/tmp/mm_persist_modedep_%u", + static_cast(mm::platform::millis())); + std::filesystem::remove_all(tmpRoot); + mm::platform::fsSetRoot(tmpRoot); + mm::ModuleFactory::registerType("ModeDependentMock"); + + // --- Save: set mode=1 (the non-default control set) AND param=3 on the mode-1 variable --- + { + mm::Scheduler scheduler; + auto* fs = new mm::FilesystemModule(); + fs->setTypeName("FilesystemModule"); + fs->setScheduler(&scheduler); + auto* m = new ModeDependentMock(); + m->setTypeName("ModeDependentMock"); + scheduler.addModule(fs); + scheduler.addModule(m); + scheduler.setup(); + + m->mode = 1; + m->rebuildControls(); // mode change re-binds `param` to paramB (as the UI swap would) + m->paramB = 3; // the value the user sets (the "clockPin=3" analogue) + m->markDirty(); + mm::FilesystemModule::noteDirty(); + fs->flush(); + scheduler.release(); + } + + // --- Load: fresh module (mode defaults to 0, param bound to paramA=10). The reload must end with + // mode=1 AND paramB=3 β€” NOT paramB=10 (the bug: param landed on paramA, then the mode-1 rebuild + // showed paramB at its default). --- + { + mm::Scheduler scheduler; + auto* fs = new mm::FilesystemModule(); + fs->setTypeName("FilesystemModule"); + fs->setScheduler(&scheduler); + auto* m = new ModeDependentMock(); + m->setTypeName("ModeDependentMock"); + scheduler.addModule(fs); + scheduler.addModule(m); + scheduler.setup(); + + CHECK(m->mode == 1); // the value-dependent selector restored + CHECK(m->paramB == 3); // and the control it selects restored to the SAVED value, not default 10 + scheduler.release(); + } + + std::filesystem::remove_all(tmpRoot); + mm::platform::fsSetRoot("."); +} + +// Regression: a user-added module recorded AFTER two code-wired siblings must survive a load even when +// the code-wired siblings' boot order differs from the saved order. This is shiffy's "spontaneously lost +// ParallelLedDriver" bug: the Drivers container boot-wires LightPresets then Preview, but the file was +// saved as Preview(0), LightPresets(1), ParallelLed(2). The old positional reconciler hit index 0 +// (JSON: Preview, live: LightPresets, code-wired) and BROKE, dropping the ParallelLedDriver at index 2 +// on every reboot. The align pass reorders the code-wired children to the saved indices first, so the +// user module is reached and restored. Modeled with two code-wired effects (singletons per container, +// like Preview/LightPresets) swapped vs. the file, plus a user effect after them. +TEST_CASE("FilesystemModule restores a user module recorded after reordered code-wired siblings") { + char tmpRoot[256]; + std::snprintf(tmpRoot, sizeof(tmpRoot), "/tmp/mm_wired_reorder_%u", + static_cast(mm::platform::millis())); + std::filesystem::remove_all(tmpRoot); + std::filesystem::create_directories(std::string(tmpRoot) + "/.config"); + mm::platform::fsSetRoot(tmpRoot); + + mm::ModuleFactory::registerType("Layer"); + mm::ModuleFactory::registerType("RainbowEffect"); + mm::ModuleFactory::registerType("NoiseEffect"); + mm::ModuleFactory::registerType("MultiplyModifier"); + + // Saved file: child order Noise(0), Rainbow(1), Multiply(2) β€” the two effects are the code-wired + // singletons, the modifier is the user-added module recorded AFTER them. The Rainbow carries a + // DISTINCTIVE saved `speed` (137, not its default 20) so the test can prove a reordered code-wired + // child's own persisted controls are restored, not just its presence. + { + std::ofstream f(std::string(tmpRoot) + "/.config/Layer.json"); + f << "{\"channelsPerLight\":3,\"enabled\":true," + "\"0.type\":\"NoiseEffect\",\"0.enabled\":true," + "\"1.type\":\"RainbowEffect\",\"1.speed\":137,\"1.enabled\":true," + "\"2.type\":\"MultiplyModifier\",\"2.enabled\":true}"; + } + + mm::Scheduler scheduler; + auto* fs = new mm::FilesystemModule(); + fs->setTypeName("FilesystemModule"); + fs->setScheduler(&scheduler); + + // Live boot tree: the two code-wired effects in the OPPOSITE order from the file β€” Rainbow(0), + // Noise(1) β€” and NO user modifier yet (it is what the file must restore). + auto* layer = new mm::Layer(); + layer->setTypeName("Layer"); + auto* rainbow = new mm::RainbowEffect(); rainbow->setTypeName("RainbowEffect"); rainbow->markWiredByCode(); + auto* noise = new mm::NoiseEffect(); noise->setTypeName("NoiseEffect"); noise->markWiredByCode(); + layer->addChild(rainbow); // live index 0 (file says index 1) + layer->addChild(noise); // live index 1 (file says index 0) + + scheduler.addModule(fs); + scheduler.addModule(layer); + scheduler.setup(); + + // After load: the user MultiplyModifier must be restored (the BUG dropped it), the two code-wired + // effects preserved. The user module lands AFTER the code-wired pair (it is appended in file order + // once the code-wired positions are consumed). The code-wired pair's relative order is cosmetic (both + // are boot singletons, not render-order-sensitive) and self-corrects on the next save, so this asserts + // presence + the user module's position, not the code-wired pair's exact order. + REQUIRE(layer->childCount() == 3); + // The user module is at the tail, restored (this is the regression: it used to vanish). + CHECK(std::strcmp(layer->child(2)->typeName(), "MultiplyModifier") == 0); + CHECK(layer->child(2)->isWiredByCode() == false); + // Both code-wired effects survive (in either order β€” cosmetic). + bool haveNoise = false, haveRainbow = false; + mm::RainbowEffect* liveRainbow = nullptr; + for (uint8_t k = 0; k < 2; k++) { + const char* t = layer->child(k)->typeName(); + CHECK(layer->child(k)->isWiredByCode() == true); + if (std::strcmp(t, "NoiseEffect") == 0) haveNoise = true; + if (std::strcmp(t, "RainbowEffect") == 0) { haveRainbow = true; liveRainbow = static_cast(layer->child(k)); } + } + CHECK(haveNoise); + CHECK(haveRainbow); + // The reordered code-wired Rainbow's OWN persisted control is restored β€” not just its presence. Its + // saved index (1) differs from its boot index (0), so this is the reorder case the fix covers: the + // reconciler finds the JSON entry naming RainbowEffect and overlays its `speed` (137, not default 20). + REQUIRE(liveRainbow != nullptr); + CHECK(liveRainbow->speed == 137); + + scheduler.release(); + std::filesystem::remove_all(tmpRoot); + mm::platform::fsSetRoot("."); +} + +// Regression: an UNKNOWN entry BEFORE a boot-wired child must not spawn a DUPLICATE of the wired child. +// The stale-slot branch restores a mismatched wired child from a later matching JSON entry β€” but that +// later entry is ALSO walked by the main loop, and if pos has moved past the wired child, the loop would +// factory-create a second instance of the wired type (two Rainbows). The reconciler must consume the +// wired child's saved entry once, not twice. +TEST_CASE("FilesystemModule: an unknown entry before a wired child does not duplicate the wired child") { + char tmpRoot[256]; + std::snprintf(tmpRoot, sizeof(tmpRoot), "/tmp/mm_wired_dup_%u", + static_cast(mm::platform::millis())); + std::filesystem::remove_all(tmpRoot); + std::filesystem::create_directories(std::string(tmpRoot) + "/.config"); + mm::platform::fsSetRoot(tmpRoot); + + mm::ModuleFactory::registerType("Layer"); + mm::ModuleFactory::registerType("RainbowEffect"); + + // Saved file: an UNKNOWN type at entry 0 (GoneEffect never registers), then RainbowEffect at entry 1. + // The live tree has ONE boot-wired RainbowEffect. + { + std::ofstream f(std::string(tmpRoot) + "/.config/Layer.json"); + f << "{\"channelsPerLight\":3,\"enabled\":true," + "\"0.type\":\"GoneEffect\",\"0.enabled\":true," + "\"1.type\":\"RainbowEffect\",\"1.speed\":91,\"1.enabled\":true}"; + } + + mm::Scheduler scheduler; + auto* fs = new mm::FilesystemModule(); + fs->setTypeName("FilesystemModule"); + fs->setScheduler(&scheduler); + auto* layer = new mm::Layer(); + layer->setTypeName("Layer"); + auto* rainbow = new mm::RainbowEffect(); rainbow->setTypeName("RainbowEffect"); rainbow->markWiredByCode(); + layer->addChild(rainbow); // the ONE boot-wired Rainbow at live index 0 + scheduler.addModule(fs); + scheduler.addModule(layer); + scheduler.setup(); + + // EXACTLY ONE Rainbow β€” the unknown GoneEffect drops, and the RainbowEffect JSON entry restores the + // wired child's controls without spawning a second instance. + uint8_t rainbows = 0; + for (uint8_t k = 0; k < layer->childCount(); k++) + if (std::strcmp(layer->child(k)->typeName(), "RainbowEffect") == 0) rainbows++; + CHECK(rainbows == 1); + CHECK(layer->childCount() == 1); + // And the wired child's saved control was applied (not left at default). + REQUIRE(layer->childCount() >= 1); + CHECK(static_cast(layer->child(0))->speed == 91); + + scheduler.release(); + std::filesystem::remove_all(tmpRoot); + mm::platform::fsSetRoot("."); +} + +// User-module reorder must round-trip: the drag-reorder UI (moveChildTo) permutes children, saves the +// new order, and on reboot the reconciler must restore THAT order (user-module order is meaningful β€” +// render/composite order). This is the invariant the reconciliation fix must not break: user modules are +// created fresh in file order, so a saved [B, A] loads as [B, A]. Two user effects, no code-wired +// children, saved in a non-boot order. +TEST_CASE("FilesystemModule restores a user-module reorder in the saved order") { + char tmpRoot[256]; + std::snprintf(tmpRoot, sizeof(tmpRoot), "/tmp/mm_user_reorder_%u", + static_cast(mm::platform::millis())); + std::filesystem::remove_all(tmpRoot); + std::filesystem::create_directories(std::string(tmpRoot) + "/.config"); + mm::platform::fsSetRoot(tmpRoot); + + mm::ModuleFactory::registerType("Layer"); + mm::ModuleFactory::registerType("RainbowEffect"); + mm::ModuleFactory::registerType("NoiseEffect"); + + // Saved file: the user reordered to Noise(0), Rainbow(1) β€” neither is code-wired. + { + std::ofstream f(std::string(tmpRoot) + "/.config/Layer.json"); + f << "{\"channelsPerLight\":3,\"enabled\":true," + "\"0.type\":\"NoiseEffect\",\"0.enabled\":true," + "\"1.type\":\"RainbowEffect\",\"1.enabled\":true}"; + } + + mm::Scheduler scheduler; + auto* fs = new mm::FilesystemModule(); + fs->setTypeName("FilesystemModule"); + fs->setScheduler(&scheduler); + // Live boot tree: an empty Layer (user effects are not boot-wired β€” the reconciler creates them). + auto* layer = new mm::Layer(); + layer->setTypeName("Layer"); + scheduler.addModule(fs); + scheduler.addModule(layer); + scheduler.setup(); + + // The saved order is restored exactly. + REQUIRE(layer->childCount() == 2); + CHECK(std::strcmp(layer->child(0)->typeName(), "NoiseEffect") == 0); + CHECK(std::strcmp(layer->child(1)->typeName(), "RainbowEffect") == 0); + + scheduler.release(); + std::filesystem::remove_all(tmpRoot); + mm::platform::fsSetRoot("."); +} + +// The EXACT shiffy scenario: an UNKNOWN/renamed type mid-list (a pre-consolidation MoonLedDriver that no +// longer registers) followed by a real USER module. The renamed entry must drop WITHOUT taking the user +// module after it β€” the old `break` dropped the tail, so the user's real driver vanished on every reboot. +// This is the dominant cause on shiffy (distinct from the code-wired-reorder case above), pinned here. +TEST_CASE("FilesystemModule skips an unknown type mid-list and keeps the user module after it") { + char tmpRoot[256]; + std::snprintf(tmpRoot, sizeof(tmpRoot), "/tmp/mm_unknown_midlist_%u", + static_cast(mm::platform::millis())); + std::filesystem::remove_all(tmpRoot); + std::filesystem::create_directories(std::string(tmpRoot) + "/.config"); + mm::platform::fsSetRoot(tmpRoot); + + mm::ModuleFactory::registerType("Layer"); + mm::ModuleFactory::registerType("RainbowEffect"); + mm::ModuleFactory::registerType("MultiplyModifier"); + // Deliberately do NOT register "GoneEffect" β€” it stands in for a renamed/removed type (MoonLedDriver). + + // Saved file: Rainbow(0), GoneEffect(1, unregistered), Multiply(2, a real user module AFTER the dead one). + { + std::ofstream f(std::string(tmpRoot) + "/.config/Layer.json"); + f << "{\"channelsPerLight\":3,\"enabled\":true," + "\"0.type\":\"RainbowEffect\",\"0.enabled\":true," + "\"1.type\":\"GoneEffect\",\"1.enabled\":true," + "\"2.type\":\"MultiplyModifier\",\"2.enabled\":true}"; + } + + mm::Scheduler scheduler; + auto* fs = new mm::FilesystemModule(); + fs->setTypeName("FilesystemModule"); + fs->setScheduler(&scheduler); + auto* layer = new mm::Layer(); // empty; the reconciler creates the (known) children + layer->setTypeName("Layer"); + scheduler.addModule(fs); + scheduler.addModule(layer); + scheduler.setup(); + + // The unknown GoneEffect is dropped; Rainbow AND the user's Multiply (recorded AFTER the dead entry) + // both survive β€” the whole point of the fix. Before it, the `break` on GoneEffect dropped Multiply too. + REQUIRE(layer->childCount() == 2); + CHECK(std::strcmp(layer->child(0)->typeName(), "RainbowEffect") == 0); + CHECK(std::strcmp(layer->child(1)->typeName(), "MultiplyModifier") == 0); + + scheduler.release(); + std::filesystem::remove_all(tmpRoot); + mm::platform::fsSetRoot("."); +} diff --git a/test/unit/core/unit_HttpServerModule_apply.cpp b/test/unit/core/unit_HttpServerModule_apply.cpp index 8753f6a9..7aa08ebe 100644 --- a/test/unit/core/unit_HttpServerModule_apply.cpp +++ b/test/unit/core/unit_HttpServerModule_apply.cpp @@ -119,6 +119,38 @@ TEST_CASE("apply-core: applyAddModule adds a child, idempotent on the id") { s.deleteTree(root); } +// applyAddModule reports the created module's FINAL name (post-disambiguation) via outName, so the +// HTTP handler can return it and the UI can select + focus the new module (the "+" focus fix). +TEST_CASE("apply-core: applyAddModule reports the created name, disambiguated") { + registerTestTypes(); + mm::Scheduler s; + auto* root = new Box(); + root->setName("Root"); + s.addModule(root); + mm::HttpServerModule http; + http.setScheduler(&s); + using OpResult = mm::HttpServerModule::OpResult; + + // An explicit id comes back verbatim. + char name[32] = {}; + REQUIRE(http.applyAddModule("Knob", "First", "Root", name, sizeof(name)) == OpResult::Ok); + CHECK(std::strcmp(name, "First") == 0); + + // No explicit id β†’ the type name is the base; a second same-type add disambiguates, and the + // REPORTED name is the disambiguated one (what the UI must select, not the colliding base). + char n1[32] = {}, n2[32] = {}; + REQUIRE(http.applyAddModule("Knob", "", "Root", n1, sizeof(n1)) == OpResult::Ok); + REQUIRE(http.applyAddModule("Knob", "", "Root", n2, sizeof(n2)) == OpResult::Ok); + CHECK(std::strcmp(n1, n2) != 0); // the two got distinct names + CHECK(childNamed(root, n1) != nullptr); // each reported name resolves to a real child + CHECK(childNamed(root, n2) != nullptr); + + // outName is optional β€” the APPLY_OP transport passes nullptr and must still succeed. + CHECK(http.applyAddModule("Knob", "NoReport", "Root") == OpResult::Ok); + + s.deleteTree(root); +} + TEST_CASE("apply-core: applySetControl writes a value, rejects out-of-range / unknown") { registerTestTypes(); mm::Scheduler s; diff --git a/test/unit/core/unit_TasksModule.cpp b/test/unit/core/unit_TasksModule.cpp index 57326c99..0e9212cc 100644 --- a/test/unit/core/unit_TasksModule.cpp +++ b/test/unit/core/unit_TasksModule.cpp @@ -95,19 +95,70 @@ TEST_CASE("TasksModule: the tasks list renders the injected RTOS tasks with thei REQUIRE(src != nullptr); REQUIRE(src->listRowCount() == 2); + // Rows hold a STABLE order across refreshes (the RTOS snapshot order is unstable β€” it shifts as + // tasks change state β€” which made the once-a-second list jump). projectMM's own tasks float to the + // top (the render task "main" and "mm"-prefixed workers), RTOS system tasks sink below, alphabetical + // within each group. So "main" (ours) is row 0 and "IDLE1" (system) is row 1. JsonSink r0; src->writeListRow(r0, 0); std::string row0(r0.data()); - CHECK(row0.find("\"name\":\"main\"") != std::string::npos); + CHECK(row0.find("\"name\":\"main\"") != std::string::npos); // our render task, floated to the top CHECK(row0.find("\"state\":\"running\"") != std::string::npos); CHECK(row0.find("\"core\":0") != std::string::npos); CHECK(row0.find("\"prio\":1") != std::string::npos); CHECK(row0.find("\"stack\":2340") != std::string::npos); - CHECK(row0.find("\"cpu\":47.9") != std::string::npos); // real measurement β†’ shown + CHECK(row0.find("\"cpu\":47.9") != std::string::npos); // real measurement β†’ shown JsonSink r1; src->writeListRow(r1, 1); std::string row1(r1.data()); - CHECK(row1.find("\"name\":\"IDLE1\"") != std::string::npos); - CHECK(row1.find("\"cpu\":") == std::string::npos); // kTaskCpuUnmeasured β†’ field omitted + CHECK(row1.find("\"name\":\"IDLE1\"") != std::string::npos); // system task, sunk below ours + CHECK(row1.find("\"cpu\":") == std::string::npos); // kTaskCpuUnmeasured β†’ field omitted +} + +// The row order: projectMM's OWN tasks (render "main" + "mm"-prefixed workers) float to the top so the +// user sees them first, RTOS system tasks sink below, alphabetical within each group β€” regardless of the +// (unstable) order the RTOS snapshot returns them in. Feed a deliberately-jumbled snapshot and pin the +// exact resulting order. +TEST_CASE("TasksModule: projectMM tasks sort to the top, system tasks below, alphabetical within") { + // Input order is scrambled (system, ours, system, ours, ...) β€” exactly the kind of shuffle the RTOS + // snapshot produces frame to frame. + const platform::TaskInfo snap[] = { + {"Tmr Svc", platform::TaskState::Blocked, -1, 1, 900, platform::kTaskCpuUnmeasured}, + {"mmSnap", platform::TaskState::Blocked, 1, 5, 700, platform::kTaskCpuUnmeasured}, + {"IDLE0", platform::TaskState::Ready, 0, 0, 500, platform::kTaskCpuUnmeasured}, + {"main", platform::TaskState::Running, 0, 1, 2340, platform::kTaskCpuUnmeasured}, + {"esp_timer", platform::TaskState::Blocked, -1, 22, 800, platform::kTaskCpuUnmeasured}, + {"mmEncode", platform::TaskState::Ready, 1, 5, 600, platform::kTaskCpuUnmeasured}, + }; + platform::setTestTaskSnapshot(snap, 6, "main"); + + Scheduler scheduler; + TasksModule tasks; + scheduler.addModule(&tasks); + scheduler.setup(); + tasks.tick1s(); + + const ListSource* src = tasksSource(tasks); + REQUIRE(src != nullptr); + REQUIRE(src->listRowCount() == 6); + + // Extract each row's task name in order. + auto rowName = [&](uint8_t i) { + JsonSink s; src->writeListRow(s, i); + std::string row(s.data()); + size_t p = row.find("\"name\":\""); + if (p == std::string::npos) return std::string(); + p += 8; + return row.substr(p, row.find('"', p) - p); + }; + + // Ours first (main, then mm* alphabetical: mmEncode < mmSnap), then system alphabetical + // (IDLE0 < Tmr Svc < esp_timer β€” uppercase sorts before lowercase). + CHECK(rowName(0) == "main"); + CHECK(rowName(1) == "mmEncode"); + CHECK(rowName(2) == "mmSnap"); + CHECK(rowName(3) == "IDLE0"); + CHECK(rowName(4) == "Tmr Svc"); + CHECK(rowName(5) == "esp_timer"); } TEST_CASE("TasksModule: the render task's detail nests the modules + the βˆ‘/tick cross-check") { @@ -132,6 +183,7 @@ TEST_CASE("TasksModule: the render task's detail nests the modules + the βˆ‘/tic const ListSource* src = tasksSource(tasks); REQUIRE(src != nullptr); + // projectMM's tasks float to the top: the render task "main" (ours) is row 0, "IDLE1" (system) is row 1. // Row 0 ("main") = the render task β†’ its detail nests the modules + the cross-check line. JsonSink d0; src->writeListRowDetail(d0, 0); std::string det0(d0.data()); diff --git a/test/unit/light/unit_Drivers_rendersplit.cpp b/test/unit/light/unit_Drivers_rendersplit.cpp index 0d62b409..871876d9 100644 --- a/test/unit/light/unit_Drivers_rendersplit.cpp +++ b/test/unit/light/unit_Drivers_rendersplit.cpp @@ -102,8 +102,59 @@ struct Rig { } }; +// File-static the quiesce-render hook reads (the hook is a non-capturing function pointer). Set to a +// rig's drivers for the layout-mutation test below; null the rest of the time. +mm::Drivers* g_hookDrivers = nullptr; + +// RAII: install the quiesce-render hook pointing at `d`, and ALWAYS clear both the hook and the file +// static on scope exit β€” even if a REQUIRE throws past the test body. Without this a failed assertion +// would leave the process-global hook installed with g_hookDrivers dangling at a destroyed rig, so the +// next test's addChild would call quiesceRenderSplit() on freed memory (a cascade crash). +struct HookGuard { + explicit HookGuard(mm::Drivers* d) { + g_hookDrivers = d; + mm::MoonModule::setQuiesceRenderHook([] { if (g_hookDrivers) g_hookDrivers->quiesceRenderSplit(); }); + } + ~HookGuard() { mm::MoonModule::setQuiesceRenderHook(nullptr); g_hookDrivers = nullptr; } +}; + } // namespace +// DIAGNOSTIC (bench flap): a SINGLE enabled layer + one driver + multicore. On the bench renderWait +// alternated on/off second-to-second. needOutput is false here (one layer, no LUT), so the split is +// held only by splitWanted forcing outputBuffer_. Tick many frames and assert the split stays STABLY +// engaged β€” never flaps off β€” with no config change between ticks. +TEST_CASE("render-split: a single-layer multicore config stays engaged across many ticks (no flap)") { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layers layers; + mm::Layer layer; + mm::Drivers drivers; + grid.width = 64; grid.height = 1; grid.depth = 1; + layouts.addChild(&grid); + layer.setChannelsPerLight(3); + layers.addChild(&layer); // ONE layer (the bench config), not two + layers.setLayouts(&layouts); + drivers.setLayers(&layers); + layers.applyState(); + + MockDriver d; + drivers.addChild(&d); + drivers.setup(); + drivers.prepare(); + REQUIRE(drivers.renderSplitActive()); // splitWanted forces the buffer even for one no-LUT layer + + // Tick 30 frames WITHOUT touching config. The split must not disengage on its own. renderSplitActive() + // is a state flag set/cleared synchronously in prepare()/tick(), not by worker timing, so it can be + // asserted right after each tick() β€” no sleep needed (a sleep would only add flake, not synchronization). + for (int i = 0; i < 30; i++) { + drivers.tick(); + CHECK(drivers.renderSplitActive()); // any false here reproduces the bench flap + } + + drivers.release(); +} + TEST_CASE("render-split: multicore on β†’ every driver ticks on the worker, never on a torn frame") { Rig r(64); MockDriver a, b; // two drivers: BOTH move to core 1, no per-driver opt-out @@ -218,6 +269,94 @@ TEST_CASE("render-split: deleting a driver WHILE core 1 is inside its tick() is r.drivers.release(); } +// The SIBLING-SUBTREE case: mutating a node OUTSIDE the Drivers subtree β€” here a LAYOUT β€” while the +// encode worker runs. The worker ticks the drivers, and a driver walks the whole tree +// (PreviewDriver::sendFrame β†’ Layouts::forEachCoord), so freeing a layout mid-walk is a use-after-free +// EVEN THOUGH the mutated node's parent (Layouts) owns no worker. `this->quiesce()` alone misses it β€” +// Layouts::quiesce() is the no-op default. The fix routes MoonModule::quiesceForMutation() through the +// quiesce-render HOOK, which reaches the render worker wherever it lives. This is the exact crash seen +// replacing a layout on a running split device (LoadProhibited); the test pins it via the hook. +TEST_CASE("render-split: mutating a LAYOUT while core 1 runs is safe via the quiesce-render hook") { + Rig r(64); + // A driver whose tick() reads the shared source buffer β€” enough to keep the worker busy on core 1. + auto* slow = new SlowDriver(); + r.drivers.addChild(slow); + r.drivers.setup(); + r.drivers.prepare(); + REQUIRE(r.drivers.renderSplitActive()); + + // Wire the quiesce-render hook the way main.cpp does (routed through a file static since the hook is + // a non-capturing function pointer). The RAII guard clears it on every exit path, including a thrown + // REQUIRE β€” so a failure here can't dangle the hook into a later test. + HookGuard hook(&r.drivers); + + r.drivers.tick(); // notify core 1 β†’ worker enters slow->tick() + REQUIRE(slow->waitEntered()); + CHECK(slow->isInTick()); // worker mid-tick, holding the tree + + std::atomic releaseFired{false}; + std::thread releaser([&] { + std::this_thread::sleep_for(30ms); + releaseFired.store(true); + slow->letGo(); + }); + + // Mutate a node OUTSIDE the Drivers subtree: remove the grid from Layouts. quiesceForMutation() + // must reach the render worker through the hook and block until it is out β€” WITHOUT the hook this + // returns immediately and a later free of `grid` would be a use-after-free (ASan) while the worker + // is still walking it. + r.layouts.removeChild(&r.grid); + + CHECK(releaseFired.load()); // it waited for the encode (fails without the hook) + CHECK_FALSE(slow->isInTick()); // the worker is provably out + + releaser.join(); + + r.drivers.release(); + delete slow; + // The HookGuard clears the hook + g_hookDrivers on scope exit (including a thrown REQUIRE above). +} + +// The FOURTH mutator: reordering children (the drag-reorder UI β†’ moveChildTo) permutes children_ under +// the worker's index-based tick loop. No free, so not a use-after-free β€” but a slot shift mid-loop can +// tick a child twice or skip one (the data-race class the batch closes). moveChildTo must quiesce like +// the other three. Same harness: park the worker inside a driver tick, reorder Drivers' children, and +// prove moveChildTo waited for the worker (fails without the quiesce). +TEST_CASE("render-split: reordering children (moveChildTo) while core 1 runs waits for the worker") { + Rig r(64); + auto* slow = new SlowDriver(); + auto* second = new SlowDriver(); // a second driver so there IS a reorder to do + r.drivers.addChild(slow); + r.drivers.addChild(second); + r.drivers.setup(); + r.drivers.prepare(); + REQUIRE(r.drivers.renderSplitActive()); + + HookGuard hook(&r.drivers); + + r.drivers.tick(); // worker enters the first driver's tick() + REQUIRE(slow->waitEntered()); + CHECK(slow->isInTick()); + + std::atomic releaseFired{false}; + std::thread releaser([&] { + std::this_thread::sleep_for(30ms); + releaseFired.store(true); + slow->letGo(); + second->letGo(); // release both in case the worker moved on to the second + }); + + r.drivers.moveChildTo(second, 0); // reorder while the worker is mid-tick + + CHECK(releaseFired.load()); // moveChildTo waited for the encode (fails without quiesce) + CHECK_FALSE(slow->isInTick()); + + releaser.join(); + r.drivers.release(); + delete slow; + delete second; +} + TEST_CASE("render-split: toggling multicore live engages and disengages the worker") { Rig r(64); MockDriver d; diff --git a/test/unit/light/unit_LightPresetsModule.cpp b/test/unit/light/unit_LightPresetsModule.cpp index 3a81c700..00809910 100644 --- a/test/unit/light/unit_LightPresetsModule.cpp +++ b/test/unit/light/unit_LightPresetsModule.cpp @@ -209,6 +209,55 @@ TEST_CASE("LightPresets: a custom preset round-trips through persistence with it CHECK(c.offWhite == 3); // W at channel 3 survived } +// Regression: a preset name containing a JSON metacharacter (a double-quote or a backslash) must +// round-trip through persistence. writeListRow escapes the name via writeJsonString; a raw %s would +// emit malformed JSON that fails to parse on the next boot, SILENTLY WIPING every custom preset β€” a +// legal keystroke ("MH \"BeeEyes\"") wiping the whole library. The persisted form must be valid JSON +// for any name the editor accepts. +TEST_CASE("LightPresets: a preset name with a quote or backslash survives persistence") { + LightPresetsModule src; + src.setup(); + uint32_t id = 0; + src.addListRow(id); + // A name carrying both a double-quote and a backslash β€” exactly what breaks a raw %s emit. The + // field-edit path decodes JSON escapes, so this lands the literal 5 chars A"B\C in the name. + src.setListRowField(id, "name", "{\"value\":\"A\\\"B\\\\C\"}"); + src.setListRowField(id, "channels", "{\"value\":3}"); + const std::string rows = presetsJson(src); + REQUIRE(!rows.empty()); + + LightPresetsModule dst; + const std::string wrapped = "{\"presets\":" + rows + "}"; + CHECK(dst.restoreList(wrapped.c_str(), "presets")); // MUST parse β€” the whole point + CHECK(dst.listRowCount() == kBuiltinCount + 1); // the custom preset survived, not wiped + // And the driver can still resolve it (the row is intact, not a half-parsed remnant). + Correction c; + CHECK(dst.deriveCorrection(id, 255, c)); +} + +// Regression: a persisted role byte out of the valid ChannelRole range (a hand-edited / corrupt file) +// must clamp to a safe default on restore, matching the validation setListRowField applies on the live +// edit path. An unclamped cast would store e.g. 250, which the UI's role Select mis-renders and the +// derived Correction silently drops β€” the "corrupted-but-looks-fine" outcome the robustness contract +// forbids. (The load path is ApplyPolicy::Clamp: a stale/bad value snaps to a valid one, never survives.) +TEST_CASE("LightPresets: an out-of-range persisted role clamps to a default on restore") { + // A hand-authored persisted array with role bytes past kChannelRoleCount (250) and a negative (-1). + // R (role 1) sits at channel 2 β€” a non-default offset, so a passing offRed==2 proves the value came + // from the restored roles, not Correction's built-in default (offRed=1). + const std::string wrapped = + "{\"presets\":[{\"id\":900,\"name\":\"corrupt\",\"channels\":3,\"roles\":[250,-1,1]}]}"; + LightPresetsModule dst; + CHECK(dst.restoreList(wrapped.c_str(), "presets")); + CHECK(dst.listRowCount() == 1); // this hand-authored file carries only the one corrupt preset + + // The valid role (1 = R) at channel 2 survives; the two bad ones (250, -1) become 0 (None), not + // 250/255. If they hadn't clamped, no channel would carry R and offRed would fall back to default. + Correction c; + REQUIRE(dst.deriveCorrection(900, 255, c)); + CHECK(c.outChannels == 3); + CHECK(c.offRed == 2); // R restored at channel 2 β€” proves the in-range role survived the clamp +} + // Driver reference (Inc 2): a driver's `preset` Select is populated from the library and picking a // preset changes the driver's resolved Correction. This would have caught the "(none)" bug where the // library seat was claimed too late (in prepare, phase 4) for the driver's defineControls (phase 1) @@ -226,7 +275,7 @@ TEST_CASE("A driver's preset Select is populated from the library, and picking o // The preset Select exists and its options are the library's names β€” NOT the "(none)" fallback. const mm::ControlDescriptor* preset = nullptr; for (uint8_t i = 0; i < drv.controls().count(); i++) - if (std::strcmp(drv.controls()[i].name, "preset") == 0) preset = &drv.controls()[i]; + if (std::strcmp(drv.controls()[i].name, "lightPreset") == 0) preset = &drv.controls()[i]; REQUIRE(preset != nullptr); REQUIRE(preset->max >= kBuiltinCount); // option count: the built-ins at least (max carries it for a Select) const auto* opts = reinterpret_cast(preset->aux); @@ -244,7 +293,7 @@ TEST_CASE("A driver's preset Select is populated from the library, and picking o // re-syncs the index back to the old id and the pick is silently reverted (the live bug). *static_cast(preset->ptr) = 1; // select index 1 = GRB drv.rebuildControls(); // production order: rebuild FIRST - drv.onControlChanged("preset"); // then the change reaction + drv.onControlChanged("lightPreset"); // then the change reaction CHECK(drv.correctionForTest().offGreen == 0); // GRB: G at 0 β€” the pick took, not reverted CHECK(drv.correctionForTest().offRed == 1); // R at 1 } @@ -273,10 +322,10 @@ TEST_CASE("Editing a preset flows to every driver referencing it (consistency)") // Point each driver at "Shared" by name (the persisted reference), then resolve. // (setDefaultPresetName is protected; drive it via the Select instead.) for (uint8_t i = 0; i < d->controls().count(); i++) - if (std::strcmp(d->controls()[i].name, "preset") == 0) + if (std::strcmp(d->controls()[i].name, "lightPreset") == 0) *static_cast(d->controls()[i].ptr) = lib.indexOfId(id); d->rebuildControls(); - d->onControlChanged("preset"); + d->onControlChanged("lightPreset"); d->rebuildCorrection(255); } // Both see RGB order now. @@ -309,7 +358,7 @@ TEST_CASE("A newly-added preset becomes selectable on a driver") { drv.defineControls(); auto presetOptCount = [&]() -> int { for (uint8_t i = 0; i < drv.controls().count(); i++) - if (std::strcmp(drv.controls()[i].name, "preset") == 0) + if (std::strcmp(drv.controls()[i].name, "lightPreset") == 0) return drv.controls()[i].max; // Select option count rides `max` return -1; }; @@ -323,10 +372,10 @@ TEST_CASE("A newly-added preset becomes selectable on a driver") { // And it's actually selectable + resolves: pick the last index, rebuild, no out-of-range. for (uint8_t i = 0; i < drv.controls().count(); i++) - if (std::strcmp(drv.controls()[i].name, "preset") == 0) + if (std::strcmp(drv.controls()[i].name, "lightPreset") == 0) *static_cast(drv.controls()[i].ptr) = lib.indexOfId(id); drv.rebuildControls(); - drv.onControlChanged("preset"); + drv.onControlChanged("lightPreset"); mm::Correction c; CHECK(lib.deriveCorrection(id, 255, c)); // the driver now references the new preset, resolves fine } @@ -349,10 +398,10 @@ TEST_CASE("whiteMode is hidden for a no-white preset, shown for an RGBW one") { }; auto pickPreset = [&](uint8_t idx) { for (uint8_t i = 0; i < drv.controls().count(); i++) - if (std::strcmp(drv.controls()[i].name, "preset") == 0) + if (std::strcmp(drv.controls()[i].name, "lightPreset") == 0) *static_cast(drv.controls()[i].ptr) = idx; drv.rebuildControls(); // buildPresetOptions maps idxβ†’id - drv.onControlChanged("preset"); + drv.onControlChanged("lightPreset"); }; drv.defineControls(); @@ -451,10 +500,10 @@ TEST_CASE("A driver referencing a missing preset falls back to the default built mm::NetworkSendDriver drv; drv.defineControls(); for (uint8_t i = 0; i < drv.controls().count(); i++) - if (std::strcmp(drv.controls()[i].name, "preset") == 0) + if (std::strcmp(drv.controls()[i].name, "lightPreset") == 0) *static_cast(drv.controls()[i].ptr) = lib.indexOfId(custom); drv.rebuildControls(); - drv.onControlChanged("preset"); + drv.onControlChanged("lightPreset"); drv.rebuildCorrection(255); REQUIRE(drv.correctionForTest().outChannels == 4); // referencing the custom diff --git a/test/unit/light/unit_MoonLedDriver.cpp b/test/unit/light/unit_MoonLedDriver.cpp index c504fc0c..ebe672e2 100644 --- a/test/unit/light/unit_MoonLedDriver.cpp +++ b/test/unit/light/unit_MoonLedDriver.cpp @@ -6,18 +6,20 @@ #include "correction_presets.h" #include "light/drivers/MoonLedDriver.h" #include "light/layers/Buffer.h" +#include "unit/core/conditional_controls.h" // controlIndex/setControlValue β€” clockPin now lives on the backend #include // MoonLedDriver is the SAME LCD_CAM output as MultiPinLedDriver, on our own DMA code instead of -// esp_lcd (ADR-0014). It is a CRTP sibling of the same ParallelLedDriver base, so the base's whole -// body β€” lane slicing, frame sizing, the fused encode, the async double-buffer, the shift-register -// expander, the dead-frame guard β€” is ALREADY covered by the Mock-driver suites +// esp_lcd (ADR-0014). It is a thin ParallelLedDriver subclass whose constructor wires a +// MoonI80Peripheral backend (a runtime LedPeripheral strategy, not compile-time CRTP), so the +// orchestrator's whole body β€” lane slicing, frame sizing, the fused encode, the async double-buffer, +// the shift-register expander, the dead-frame guard β€” is ALREADY covered by the Mock-driver suites // (unit_ParallelLedDriver_doublebuffer / _shiftregister) and by unit_I80LedDriver. Re-testing it // here through a second concrete driver would assert the same base twice. // // So these cases pin only what is genuinely MoonI80's own: -// - it satisfies the CRTP contract (it instantiates and configures at all); +// - it satisfies the LedPeripheral contract (it instantiates and configures at all); // - the constants that DIFFER from its sibling β€” chiefly that it is LCD_CAM-only; // - its own bus-pin validation, and the fact that it needs FEWER pins than the sibling: no DC at // all, and WR only under the expander (owning the GPIO matrix is what buys that). @@ -27,8 +29,23 @@ namespace { -void wire(mm::MoonLedDriver& d, mm::Buffer& src, mm::Correction& corr, +// On the host `platform::lcdLanes` is 0, so the real MoonI80Peripheral reports supportsPinExpander() +// == false β€” which the orchestrator now honors by SILENTLY dropping pinExpander back to direct mode +// (a peripheral that can't host the '595 has its expander toggle hidden, so an unfixable error would be +// wrong β€” see parseConfig). That degradation is correct on hardware but hides the SHIFT-MODE validation +// (WR collision, latch-on-WR, clockPin required) these cases exist to pin. So the shift-mode cases use +// this tiny subclass that reports the expander as supported, exactly as the real backend does on an +// LCD_CAM chip β€” the validation then runs on the host. +struct ExpanderMoonI80 : mm::MoonI80Peripheral { + bool supportsPinExpander() const override { return true; } +}; + +// The peripheral is declared BEFORE the driver at every call site (see this helper's parameter +// order) so it outlives the driver β€” setPeripheralForTest borrows, it does not own (see +// unit_ParallelLedDriver_doublebuffer.cpp's wire()). +void wire(mm::ParallelLedDriver& d, mm::MoonI80Peripheral& peripheral, mm::Buffer& src, mm::Correction& corr, mm::nrOfLightsType lights) { + d.setPeripheralForTest(&peripheral); if (d.pins[0] == '\0') std::strcpy(d.pins, "1,2,4,5,6,7,8,9"); REQUIRE(src.allocate(lights, 3) == (lights > 0)); mm::test::rebuildFromPreset(corr, 255, mm::test::PresetOrder::GRB); @@ -45,18 +62,24 @@ void wire(mm::MoonLedDriver& d, mm::Buffer& src, mm::Correction& corr, // programs LCD_CAM directly, so it must NOT claim the classic chip: `lanesAvailable()` reads // `lcdLanes` alone, without the `+ i2sLanes` its sibling adds. Getting this wrong would offer the // driver on a chip whose peripheral it cannot drive. +// +// lanesAvailable()/kSupportsPinExpander/kPowerOfTwoBus/kLoopbackFullWidth moved from static constexpr +// on the driver to virtuals on the MoonI80Peripheral backend β€” a bare instance reaches them without a +// live driver's peripheral_ (protected on ParallelLedDriver). TEST_CASE("MoonLedDriver is LCD_CAM-only β€” it does not claim the classic ESP32's I2S i80") { - CHECK(mm::MoonLedDriver::lanesAvailable() == mm::platform::lcdLanes); + mm::MoonI80Peripheral peripheral; + CHECK(peripheral.lanesAvailable() == mm::platform::lcdLanes); // The expander needs LCD_CAM, which is exactly where this driver runs β€” so the two agree. - CHECK(mm::MoonLedDriver::kSupportsPinExpander == (mm::platform::lcdLanes > 0)); + CHECK(peripheral.supportsPinExpander() == (mm::platform::lcdLanes > 0)); } -// The i80 BUS is 8 or 16 bits wide whatever the pin count, so the base rounds it up (kPowerOfTwoBus) +// The i80 BUS is 8 or 16 bits wide whatever the pin count, so the base rounds it up (powerOfTwoBus()) // and parks the lanes the board does not use. And the loopback cannot build a 1-lane private bus, so // its test frame is encoded at the full operational width. TEST_CASE("MoonLedDriver keeps the i80 bus rules: power-of-two bus, full-width loopback") { - CHECK(mm::MoonLedDriver::kPowerOfTwoBus); - CHECK(mm::MoonLedDriver::kLoopbackFullWidth); + mm::MoonI80Peripheral peripheral; + CHECK(peripheral.powerOfTwoBus()); + CHECK(peripheral.loopbackFullWidth()); } // **The bus control pins are a '595 cost, not an i80 cost β€” and owning the DMA is what proves it.** @@ -71,12 +94,13 @@ TEST_CASE("MoonLedDriver keeps the i80 bus rules: power-of-two bus, full-width l // GPIO that a strand also uses, because the signal never leaves the peripheral. Rejecting that would // forbid a working config to protect a signal nobody reads. TEST_CASE("MoonLedDriver direct mode: clockPin is unrouted, so it cannot collide") { - mm::MoonLedDriver d; + mm::MoonI80Peripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; std::strcpy(d.pins, "1,2,4,5,6,7,8,10"); // pin 10 IS the default clockPin (WR) β€” fine here - wire(d, src, corr, 8 * 16); - CHECK(d.severity() != mm::MoonLedDriver::Severity::Error); + wire(d, peripheral, src, corr, 8 * 16); + CHECK(d.severity() != mm::ParallelLedDriver::Severity::Error); CHECK(d.laneCount() == 8); // it drives all eight strands } @@ -84,58 +108,74 @@ TEST_CASE("MoonLedDriver direct mode: clockPin is unrouted, so it cannot collide // sharing it is silent corruption: the matrix drives both signals onto the one pad and that strand // emits the shift clock instead of pixel data. TEST_CASE("MoonLedDriver shift mode: a data pin on clockPin (WR) is caught") { - mm::MoonLedDriver d; + ExpanderMoonI80 peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; d.pinExpander = true; d.latchPin = 12; std::strcpy(d.pins, "1,2,10"); // pin 10 IS clockPin, and here WR is a real pad - wire(d, src, corr, 8 * 16); - CHECK(d.severity() != mm::MoonLedDriver::Severity::Status); // it complains + wire(d, peripheral, src, corr, 8 * 16); + CHECK(d.severity() != mm::ParallelLedDriver::Severity::Status); // it complains } // The '595 latch rides a DATA lane (the peripheral gives only one clock output, and WR is already the // shift clock), so it must not land on WR β€” the latch would ride the shift clock itself and nothing // would ever latch, which looks like a dead strip rather than a config error. +// +// clockPin now lives on the MoonI80Peripheral backend (not a MoonLedDriver member), so it is read/set +// through the control API defineDriverControls() binds β€” the same mechanism the UI and persistence use. TEST_CASE("MoonLedDriver rejects a latchPin on WR") { - mm::MoonLedDriver d; + ExpanderMoonI80 peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; + d.setPeripheralForTest(&peripheral); + d.defineControls(); d.pinExpander = true; - d.latchPin = d.clockPin; - wire(d, src, corr, 8 * 16); - CHECK(d.severity() == mm::MoonLedDriver::Severity::Error); + const int idx = mm::test::controlIndex(d, "clockPin"); + REQUIRE(idx >= 0); + d.latchPin = *static_cast(d.controls()[static_cast(idx)].ptr); + wire(d, peripheral, src, corr, 8 * 16); + CHECK(d.severity() == mm::ParallelLedDriver::Severity::Error); } // The '595's shift clock IS WR, so shift mode needs clockPin on a real GPIO. Unset (-1) would route // the peripheral's WR signal to GPIO 65535 β€” catch it as a config error, not a bad pad write. Direct // mode does not care (WR is unrouted there), so the same unset pin is fine without the expander. TEST_CASE("MoonLedDriver shift mode requires a clockPin; direct mode does not") { - mm::MoonLedDriver d; + ExpanderMoonI80 peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; + d.setPeripheralForTest(&peripheral); + d.defineControls(); d.pinExpander = true; d.latchPin = 12; - d.clockPin = -1; // unset - wire(d, src, corr, 8 * 16); - CHECK(d.severity() == mm::MoonLedDriver::Severity::Error); + mm::test::setControlValue(d, "clockPin", -1); // unset + wire(d, peripheral, src, corr, 8 * 16); + CHECK(d.severity() == mm::ParallelLedDriver::Severity::Error); - mm::MoonLedDriver d2; // same unset clockPin, DIRECT mode β†’ fine + ExpanderMoonI80 peripheral2; + mm::ParallelLedDriver d2; // same unset clockPin, DIRECT mode β†’ fine mm::Buffer src2; mm::Correction corr2; - d2.clockPin = -1; + d2.setPeripheralForTest(&peripheral2); + d2.defineControls(); + mm::test::setControlValue(d2, "clockPin", -1); std::strcpy(d2.pins, "1,2,4,5,6,7,8,9"); - wire(d2, src2, corr2, 8 * 16); - CHECK(d2.severity() != mm::MoonLedDriver::Severity::Error); + wire(d2, peripheral2, src2, corr2, 8 * 16); + CHECK(d2.severity() != mm::ParallelLedDriver::Severity::Error); } // Sanity: with a valid config the driver is a working CRTP sibling β€” it slices lanes and reports the // lights it drives, exactly like its sibling. (The lane/frame ARITHMETIC itself is the base's, and is // covered once, in unit_I80LedDriver and the Mock suites.) TEST_CASE("MoonLedDriver drives a valid config like its sibling") { - mm::MoonLedDriver d; + mm::MoonI80Peripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 8 * 32); // 8 lanes Γ— 32 lights + wire(d, peripheral, src, corr, 8 * 32); // 8 lanes Γ— 32 lights CHECK(d.laneCount() == 8); } diff --git a/test/unit/light/unit_MultiPinLedDriver.cpp b/test/unit/light/unit_MultiPinLedDriver.cpp index 212de901..6a170d57 100644 --- a/test/unit/light/unit_MultiPinLedDriver.cpp +++ b/test/unit/light/unit_MultiPinLedDriver.cpp @@ -15,11 +15,20 @@ // growth), and the parse-error/recovery shape. The hardware half (bus init, // DMA transmit) is inert on the host β€” desktop stubs return false/nullptr β€” // and is proven on the S3. +// +// mm::ParallelLedDriver is the ONE registered driver; this file drives it with an +// injected mm::I80Peripheral backend (the esp_lcd i80 bus), the backend this header +// defines and registers under the "i80" peripheral label. namespace { -void wire(mm::MultiPinLedDriver& d, mm::Buffer& src, mm::Correction& corr, +// The peripheral is declared BEFORE the driver in every case below (via this helper's +// parameter order + the caller's declaration order) so it outlives the driver β€” reverse +// destruction order tears the driver down first, matching setPeripheralForTest's borrow +// contract (see unit_ParallelLedDriver_doublebuffer.cpp's wire()). +void wire(mm::ParallelLedDriver& d, mm::I80Peripheral& peripheral, mm::Buffer& src, mm::Correction& corr, mm::nrOfLightsType lights) { + d.setPeripheralForTest(&peripheral); // Pins default to UNSET now (the "default only when it cannot do harm" rule β€” // a user solders the strand to its own GPIOs), so a fresh driver idles until // configured. These slicing/frame tests exercise the lane logic, not the @@ -52,11 +61,12 @@ size_t expectFrame(mm::nrOfLightsType maxLights, uint8_t outCh, uint8_t slotByte // LONGEST lane. The bus always has all 8 lanes β€” unused strands take the // 0-light remainder and idle LOW. TEST_CASE("MultiPinLedDriver slices lanes and sizes the frame by the longest") { - mm::MultiPinLedDriver d; + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; std::strcpy(d.ledsPerPin, "50,20,20"); // lanes 3..7 share the remainder: 0 - wire(d, src, corr, 90); + wire(d, peripheral, src, corr, 90); REQUIRE(d.laneCount() == 8); CHECK(d.laneLightCount(0) == 50); @@ -73,10 +83,11 @@ TEST_CASE("MultiPinLedDriver slices lanes and sizes the frame by the longest") { // Empty ledsPerPin splits evenly β€” same PinList semantics the RMT driver uses. TEST_CASE("MultiPinLedDriver even split over the default 8 lanes") { - mm::MultiPinLedDriver d; + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 256); // default pins: 8 lanes + wire(d, peripheral, src, corr, 256); // default pins: 8 lanes REQUIRE(d.laneCount() == 8); CHECK(d.laneLightCount(0) == 32); @@ -87,11 +98,12 @@ TEST_CASE("MultiPinLedDriver even split over the default 8 lanes") { // An RGBβ†’RGBW preset toggle grows the frame (32 vs 24 slot bytes per light). TEST_CASE("MultiPinLedDriver frame grows on RGBW preset") { - mm::MultiPinLedDriver d; + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; std::strcpy(d.ledsPerPin, "50,50"); // lanes 2..7 idle - wire(d, src, corr, 100); + wire(d, peripheral, src, corr, 100); CHECK(d.frameBytes() == expectFrame(50, 3)); // The driver owns its Correction, so mutate that copy (not the external one). @@ -100,13 +112,55 @@ TEST_CASE("MultiPinLedDriver frame grows on RGBW preset") { CHECK(d.frameBytes() == expectFrame(50, 4)); } +// A whole-frame driver with a bounded DMA (the classic ESP32 i80 = internal-RAM-only I2S, no ring) +// must REFUSE cleanly when the frame won't fit β€” never choke the bus init (which can busy-wait to a +// watchdog reset on hardware). On desktop / PSRAM chips the budget is 0 (no bound), so the frame-fit +// gate NEVER triggers regardless of grid size: a large frame is never rejected for its size here (the +// classic-i80 branch is compiled out). Pins the "budget 0 = no bound" contract β€” the gate is inert off +// the classic chip, so this refactor changes nothing on every non-classic target. The hardware behavior +// (a too-big frame on the real classic i80 idles with the clear "over DMA" status) is proven on the Olimex. +TEST_CASE("MultiPinLedDriver: the DMA-fit gate is inert off the classic i80 (budget 0)") { + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.pins, "1"); // one lane β†’ the whole grid lands on it, a big frame + std::strcpy(d.ledsPerPin, ""); // even-split all onto the one pin + wire(d, peripheral, src, corr, 4096); // a large grid, far past any real classic i80 DMA budget + + // The frame was computed (a real size), and the status is NOT the size-rejection message β€” desktop + // dmaBudgetBytes() is 0, so frameFitsDmaBudget() always passes. (The desktop bus stub is inert, so + // the status may be the plain init-fail, but never the size gate β€” that only fires on the classic.) + CHECK(d.frameBytes() > 0); + CHECK(std::strstr(d.status() ? d.status() : "", "over i80 DMA") == nullptr); +} + +// frameFitsDmaBudget() is the pure predicate the classic-i80 gate leans on; its logic is compiled out on +// desktop (budget always 0), so exercise it directly with synthetic budgets. A FINITE budget rejects an +// oversized frame and accepts one that fits; a ZERO budget ("no bound", the LCD_CAM/PSRAM/desktop case) +// never rejects, whatever the frame size. +TEST_CASE("MultiPinLedDriver::frameFitsDmaBudget rejects only over a finite budget") { + // frameFitsDmaBudget is a protected static on ParallelLedDriver; a tiny subclass exposes it for + // the test without widening the production class surface. + struct Expose : mm::ParallelLedDriver { + using mm::ParallelLedDriver::frameFitsDmaBudget; + }; + // finite budget: reject strictly-larger, accept equal-or-smaller + CHECK_FALSE(Expose::frameFitsDmaBudget(1025, 1024)); + CHECK(Expose::frameFitsDmaBudget(1024, 1024)); + CHECK(Expose::frameFitsDmaBudget(512, 1024)); + // zero budget = no bound: never rejects, however large the frame + CHECK(Expose::frameFitsDmaBudget(1u << 30, 0)); +} + // A bad pin list idles the driver with the parse literal in the status; fixing it recovers. TEST_CASE("MultiPinLedDriver bad pins β†’ status error β†’ recovery") { - mm::MultiPinLedDriver d; + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; std::strcpy(d.pins, "1,nope"); - wire(d, src, corr, 64); + wire(d, peripheral, src, corr, 64); CHECK(d.laneCount() == 0); CHECK(d.frameBytes() == 0); @@ -125,9 +179,11 @@ TEST_CASE("MultiPinLedDriver bad pins β†’ status error β†’ recovery") { // the 8 data GPIOs on its own. (wire() back-fills empty pins for the slicing // cases, so this one wires the buffer directly to keep pins empty.) TEST_CASE("MultiPinLedDriver with the empty default pins idles cleanly") { - mm::MultiPinLedDriver d; + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; + d.setPeripheralForTest(&peripheral); REQUIRE(d.pins[0] == '\0'); // the empty default, not a bench guess REQUIRE(src.allocate(64, 3)); mm::test::rebuildFromPreset(corr, 255, mm::test::PresetOrder::GRB); @@ -151,9 +207,10 @@ TEST_CASE("MultiPinLedDriver drives any pin count; the bus rounds up around it") mm::Buffer src; mm::Correction corr; { // 3 pins β†’ 3 lanes on the 8-bit bus, the spare 5 parked on WR. - mm::MultiPinLedDriver d; + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; std::strcpy(d.pins, "1,2,4"); - wire(d, src, corr, 64); + wire(d, peripheral, src, corr, 64); CHECK(d.laneCount() == 3); CHECK(d.severity() != mm::MoonModule::Severity::Error); // 64 lights over 3 lanes β†’ the longest is 22. ≀8 pins β†’ the 8-bit bus β†’ 1-byte slots, which is @@ -162,11 +219,17 @@ TEST_CASE("MultiPinLedDriver drives any pin count; the bus rounds up around it") CHECK(d.frameBytes() == expectFrame(22, 3)); } { // 16 pins β†’ the full 16-bit bus, nothing parked. clock/dc moved clear of the data set - // (defaults 10/11 would collide with data pins 10/11 β†’ the collision guard). - mm::MultiPinLedDriver d; - d.clockPin = 20; d.dcPin = 21; + // (defaults 10/11 would collide with data pins 10/11 β†’ the collision guard). clockPin/dcPin + // live on the I80Peripheral backend, so set them via the control API ParallelLedDriver + // binds (defineDriverControls -> peripheral_->addBusControls) rather than a direct member. + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; + d.setPeripheralForTest(&peripheral); + d.defineControls(); + mm::test::setControlValue(d, "clockPin", 20); + mm::test::setControlValue(d, "dcPin", 21); std::strcpy(d.pins, "1,2,4,5,6,7,8,9,10,11,12,13,14,15,16,17"); - wire(d, src, corr, 64); + wire(d, peripheral, src, corr, 64); CHECK(d.laneCount() == 16); CHECK(d.severity() != mm::MoonModule::Severity::Error); CHECK(d.maxLaneLights() == 4); @@ -174,10 +237,14 @@ TEST_CASE("MultiPinLedDriver drives any pin count; the bus rounds up around it") } { // 10 pins β€” the case the old "exactly 8 or 16" rule rejected outright. It is a perfectly good // config: 10 data lanes on the 16-bit bus, the other 6 parked. This is the point of the change. - mm::MultiPinLedDriver d; - d.clockPin = 20; d.dcPin = 21; + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; + d.setPeripheralForTest(&peripheral); + d.defineControls(); + mm::test::setControlValue(d, "clockPin", 20); + mm::test::setControlValue(d, "dcPin", 21); std::strcpy(d.pins, "1,2,4,5,6,7,8,9,12,13"); - wire(d, src, corr, 64); + wire(d, peripheral, src, corr, 64); CHECK(d.laneCount() == 10); CHECK(d.severity() != mm::MoonModule::Severity::Error); // 64 over 10 lanes β†’ 6 each, the last taking the remainder (PinList's even-split rule) β†’ 10. @@ -197,19 +264,23 @@ TEST_CASE("MultiPinLedDriver warns (does not idle) when a data pin is on clockPi mm::Buffer src; mm::Correction corr; { // lane on GPIO 10 == default clockPin β†’ warns but still drives all 8 lanes - mm::MultiPinLedDriver d; + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; std::strcpy(d.pins, "18,5,6,7,8,9,10,11"); // 10 == clockPin, 11 == dcPin - wire(d, src, corr, 64); + wire(d, peripheral, src, corr, 64); CHECK(d.laneCount() == 8); // still built + driving REQUIRE(d.status() != nullptr); // a warning is present CHECK(std::strstr(d.status(), "clockPin") != nullptr); } { // move clock/dc clear of the data set β†’ no warning - mm::MultiPinLedDriver d; - d.clockPin = 12; - d.dcPin = 13; + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; + d.setPeripheralForTest(&peripheral); + d.defineControls(); + mm::test::setControlValue(d, "clockPin", 12); + mm::test::setControlValue(d, "dcPin", 13); std::strcpy(d.pins, "18,5,6,7,8,9,10,11"); - wire(d, src, corr, 64); + wire(d, peripheral, src, corr, 64); CHECK(d.laneCount() == 8); // No collision warning β†’ the neutral consumption info instead (not a null status). CHECK(d.severity() != mm::MoonModule::Severity::Error); @@ -219,24 +290,58 @@ TEST_CASE("MultiPinLedDriver warns (does not idle) when a data pin is on clockPi // needs two distinct control lines, so this breaks the bus outright (unlike a data-lane // collision, which only corrupts that one lane and is a warn-and-run). Routed through the // error path (validateBusFatal), so the driver idles: laneCount 0, error severity. - mm::MultiPinLedDriver d; - d.clockPin = 20; - d.dcPin = 20; // same as clockPin + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; + d.setPeripheralForTest(&peripheral); + d.defineControls(); + mm::test::setControlValue(d, "clockPin", 20); + mm::test::setControlValue(d, "dcPin", 20); // same as clockPin std::strcpy(d.pins, "1,2,3,4,5,6,7,8"); - wire(d, src, corr, 64); + wire(d, peripheral, src, corr, 64); REQUIRE(d.status() != nullptr); CHECK(std::strstr(d.status(), "same GPIO") != nullptr); CHECK(d.severity() == mm::MoonModule::Severity::Error); // idles, not warn-and-run CHECK(d.laneCount() == 0); // driver did NOT build the bus } + { // An UNSET clockPin (-1) is FATAL on i80: the int8_t -1 casts to uint16_t 65535 and slips past + // IDF's own wr/dc >= 0 guard, so validateBusFatal must reject it here or the bus inits on a + // garbage GPIO. Idles with a clear status, doesn't build. + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; + d.setPeripheralForTest(&peripheral); + d.defineControls(); + mm::test::setControlValue(d, "clockPin", -1); // unset + mm::test::setControlValue(d, "dcPin", 21); + std::strcpy(d.pins, "1,2,3,4,5,6,7,8"); + wire(d, peripheral, src, corr, 64); + REQUIRE(d.status() != nullptr); + CHECK(std::strstr(d.status(), "clockPin") != nullptr); + CHECK(d.severity() == mm::MoonModule::Severity::Error); + CHECK(d.laneCount() == 0); + } + { // Same for an unset dcPin (-1). + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; + d.setPeripheralForTest(&peripheral); + d.defineControls(); + mm::test::setControlValue(d, "clockPin", 20); + mm::test::setControlValue(d, "dcPin", -1); // unset + std::strcpy(d.pins, "1,2,3,4,5,6,7,8"); + wire(d, peripheral, src, corr, 64); + REQUIRE(d.status() != nullptr); + CHECK(std::strstr(d.status(), "dcPin") != nullptr); + CHECK(d.severity() == mm::MoonModule::Severity::Error); + CHECK(d.laneCount() == 0); + } } // A 0Γ—0Γ—0 grid is a clean idle: zero counts, zero frame (no pad for an empty frame), no crash. TEST_CASE("MultiPinLedDriver tolerates a zero-light buffer") { - mm::MultiPinLedDriver d; + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 0); + wire(d, peripheral, src, corr, 0); CHECK(d.laneCount() == 8); // pins parse fine CHECK(d.maxLaneLights() == 0); @@ -247,9 +352,11 @@ TEST_CASE("MultiPinLedDriver tolerates a zero-light buffer") { // setup/release cycles leave no residue (status clean, ASAN-checked heap). TEST_CASE("MultiPinLedDriver setup/release is repeatable") { - mm::MultiPinLedDriver d; + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; + d.setPeripheralForTest(&peripheral); src.allocate(64, 3); mm::test::rebuildFromPreset(corr, 255, mm::test::PresetOrder::GRB); std::strcpy(d.pins, "1,2,4,5,6,7,8,9"); // pins now default UNSET @@ -267,7 +374,9 @@ TEST_CASE("MultiPinLedDriver setup/release is repeatable") { // loopbackRxPin is bound always, visible only while loopbackTest is on. TEST_CASE("MultiPinLedDriver loopbackRxPin tracks the loopbackTest toggle") { - mm::MultiPinLedDriver d; + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; + d.setPeripheralForTest(&peripheral); d.defineControls(); bool found = false; for (uint8_t i = 0; i < d.controls().count(); i++) { @@ -285,10 +394,41 @@ TEST_CASE("MultiPinLedDriver loopbackRxPin tracks the loopbackTest toggle") { // contract is host-testable here via the shared helper (toggles loopbackTest both // ways and asserts the control stays bound while flipping visibility). TEST_CASE("MultiPinLedDriver loopbackTxPin tracks the loopbackTest toggle") { - mm::MultiPinLedDriver d; + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; + d.setPeripheralForTest(&peripheral); d.defineControls(); auto setTest = [&](bool on) { mm::test::setControlValue(d, "loopbackTest", on); }; mm::test::checkConditionalControl(d, "loopbackTxPin", setTest, /*visibleWhenTrue=*/true); } + +// The pinExpander switch is HIDDEN where the silicon can't host the '595 (supportsPinExpander() false: +// the classic ESP32 i80 = the I2S peripheral, whose DMA can't read PSRAM, so the Γ—8 expander frame has +// nowhere to live). Desktop has lcdLanes==0 too, so the control is hidden here β€” the same compile-time +// gate the classic build takes. On the LCD_CAM chips (S3/P4) the flag is true and the control shows; +// that path is exercised on-device. Turning it on where unsupported only ever yields a config error, so +// not offering the switch is the honest UI. (Before this, the toggle was shown on every chip.) +// +// kSupportsPinExpander moved from a static constexpr on the driver to a virtual on the I80Peripheral +// backend (supportsPinExpander()) β€” a bare I80Peripheral instance reaches it without needing a live +// driver's peripheral_ (which is protected). +TEST_CASE("MultiPinLedDriver hides pinExpander where the chip can't host it") { + mm::I80Peripheral peripheral; + CHECK_FALSE(peripheral.supportsPinExpander()); // desktop lcdLanes==0 β†’ unsupported + + mm::ParallelLedDriver d; + d.setPeripheralForTest(&peripheral); + d.defineControls(); + bool found = false; + for (uint8_t i = 0; i < d.controls().count(); i++) { + if (std::strcmp(d.controls()[i].name, "pinExpander") == 0) { + found = true; + // Hidden exactly when the chip can't host the expander β€” ties the assertion to the flag + // rather than to the desktop's happens-to-be-unsupported value, so it stays correct on any target. + CHECK(d.controls()[i].hidden == !peripheral.supportsPinExpander()); + } + } + CHECK(found); // still BOUND (a saved value survives), just not shown +} diff --git a/test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp b/test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp index 89d8550b..c77662fe 100644 --- a/test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp +++ b/test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp @@ -14,9 +14,10 @@ // Host test of the deferred-wait DOUBLE-BUFFER logic in ParallelLedDriver::tick() // (Step 1.5). The real LCD/Parlio peripherals are inert on the host (desktop stubs // return null), so the alternation/wait/drain invariants can't be exercised through -// them. Instead a MockDriver supplies the CRTP bus* hooks against two in-memory -// buffers and RECORDS the call sequence, so the base loop's behavior is pinned on -// the host exactly where the hardware would run it: +// them. Instead a MockPeripheral (a runtime LedPeripheral backend) supplies the bus* +// hooks against two in-memory buffers and RECORDS the call sequence, so the +// orchestrator's loop behavior is pinned on the host exactly where the hardware +// would run it: // - double-buffer mode alternates encode target 0,1,0,1,… and waits on a buffer // only right before it's REUSED (never after every transmit); // - single-buffer mode (mock offers no buffer 1) stays on buffer 0 and waits every @@ -28,43 +29,37 @@ namespace { using mm::nrOfLightsType; -// A record of one bus call, so a test can assert the exact ordering the base emits. +// A record of one bus call, so a test can assert the exact ordering the orchestrator emits. struct Call { enum Kind { Transmit, Wait } kind; uint8_t buffer; }; -// CRTP peripheral mock: two heap buffers, a settable "double-buffer available" flag, -// and a call log. Everything the base's tick()/reinit()/deinit() reach is here. -class MockParallelDriver : public mm::ParallelLedDriver { +// LedPeripheral mock: two heap buffers, a settable "double-buffer available" flag, +// and a call log. Everything the orchestrator's tick()/reinit()/deinit() reach is here. +class MockPeripheral : public mm::LedPeripheral { public: // --- test knobs / observation --- bool twoBuffers = true; // false β†’ single-buffer mode (busBuffer(1) == null) + bool canDoubleBuffer = true; // false β†’ this peripheral reports supportsDoubleBuffer()==false std::vector calls; // transmit/wait order, in call sequence - uint8_t activeForTest() const { return active_; } - bool inFlightForTest(uint8_t i) const { return inFlight_[i]; } - // --- CRTP hooks (mock, host-only) --- - static constexpr uint8_t lanesAvailable() { return 8; } // pretend this chip has lanes - static constexpr bool kPowerOfTwoBus = false; - static constexpr bool kLoopbackFullWidth = false; + // --- LedPeripheral hooks (mock, host-only) --- + uint8_t lanesAvailable() const override { return 8; } // pretend this chip has lanes + bool powerOfTwoBus() const override { return false; } + bool loopbackFullWidth() const override { return false; } // The mock bus is memory, not a peripheral, so it can host the 74HCT595 expander β€” which is what // lets the shift-register lane/frame arithmetic be pinned on the host (unit_ParallelSlots covers // the encoded bits; here it's the driver plumbing). - static constexpr bool kSupportsPinExpander = true; - static constexpr const char* kInitFailMsg = "mock init failed"; + bool supportsPinExpander() const override { return true; } + bool supportsDoubleBuffer() const override { return canDoubleBuffer; } // MoonI80 reports false + const char* initFailMsg() const override { return "mock init failed"; } + mm::LedHwBlock hwBlock() const override { return mm::LedHwBlock::None; } // mock drives no real block - void addBusControls() {} - bool busControlTriggersBuild(const char*) const { return false; } - void recordBusPins() {} - bool extraBusPinsCurrent() const { return true; } - const char* validateBusPins(const uint16_t*, uint8_t) const { return nullptr; } - const char* validateBusFatal() const { return nullptr; } - - // busInit gets `wantSecond` from the base (= doubleBuffer). The mock allocates the second + // busInit gets `wantSecond` from the orchestrator (= doubleBuffer). The mock allocates the second // buffer only when BOTH the flag wants it AND the test's twoBuffers knob allows it (so a test // can simulate a memory-tight board that refuses the second buffer even with async on). - bool busInit(size_t frameBytes, bool wantSecond) { + bool busInit(size_t frameBytes, bool wantSecond) override { cap_ = frameBytes; buf_[0].assign(frameBytes, 0); if (wantSecond && twoBuffers) buf_[1].assign(frameBytes, 0); @@ -72,26 +67,26 @@ class MockParallelDriver : public mm::ParallelLedDriver { inited_ = true; return true; } - uint8_t* busBuffer(uint8_t i) { + uint8_t* busBuffer(uint8_t i) override { if (i >= 2 || buf_[i].empty()) return nullptr; return buf_[i].data(); } - size_t busCapacity() const { return cap_; } - bool busTransmit(uint8_t i, size_t /*bytes*/) { + size_t busCapacity() const override { return cap_; } + bool busTransmit(uint8_t i, size_t /*bytes*/) override { calls.push_back({Call::Transmit, i}); return true; // the mock transfer always "starts" } // Returns whether the transfer completed. `waitTimesOut` makes every wait report a TIMEOUT, so a // test can prove the driver refuses to re-encode into a buffer the DMA may still be reading. - bool busWait(uint8_t i, uint32_t) { + bool busWait(uint8_t i, uint32_t) override { calls.push_back({Call::Wait, i}); return !waitTimesOut; } bool waitTimesOut = false; - uint32_t busLastTransmitUs() const { return lastTransmitUs; } // mock wire-time KPI + uint32_t busLastTransmitUs() const override { return lastTransmitUs; } // mock wire-time KPI uint32_t lastTransmitUs = 0; // a test can set this to check the frameTime string formatting - void busDeinit() { cap_ = 0; buf_[0].clear(); buf_[1].clear(); inited_ = false; } - mm::platform::RmtLoopbackResult busLoopback(const uint8_t*, size_t, size_t, uint8_t) { + void busDeinit() override { cap_ = 0; buf_[0].clear(); buf_[1].clear(); inited_ = false; } + mm::platform::RmtLoopbackResult busLoopback(const uint8_t*, size_t, size_t, uint8_t) override { return {}; } @@ -102,12 +97,13 @@ class MockParallelDriver : public mm::ParallelLedDriver { }; // Wire a mock driver onto a `lights`-light source buffer + a GRB correction, and drive it ready. -// `async` sets doubleBuffer (whether the base requests a second buffer); `canSecond` is the mock's -// board-fits-a-second-buffer knob (lets a test simulate a memory-tight board that refuses it even -// with async on). Mirrors the other parallel-driver test helpers. -void wire(MockParallelDriver& d, mm::Buffer& src, mm::Correction& corr, +// `async` sets doubleBuffer (whether the orchestrator requests a second buffer); `canSecond` is the +// mock's board-fits-a-second-buffer knob (lets a test simulate a memory-tight board that refuses it +// even with async on). Mirrors the other parallel-driver test helpers. +void wire(mm::ParallelLedDriver& d, MockPeripheral& peripheral, mm::Buffer& src, mm::Correction& corr, nrOfLightsType lights, bool async, bool canSecond = true) { - d.twoBuffers = canSecond; + peripheral.twoBuffers = canSecond; + d.setPeripheralForTest(&peripheral); d.doubleBuffer = async; std::strcpy(d.pins, "1,2,3,4"); REQUIRE(src.allocate(lights, 3) == (lights > 0)); @@ -125,10 +121,11 @@ void wire(MockParallelDriver& d, mm::Buffer& src, mm::Correction& corr, // the first two ticks transmit without a preceding wait (both buffers start idle), // and from tick 3 on each tick waits on the buffer it's about to reuse. TEST_CASE("ParallelLedDriver double-buffer alternates and defers the wait") { - MockParallelDriver d; + mm::ParallelLedDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 64, /*async=*/true); + wire(d, peripheral, src, corr, 64, /*async=*/true); // Tick 1: buffer 0 idle β†’ no wait, transmit 0, flip to 1. d.tick(); @@ -145,24 +142,25 @@ TEST_CASE("ParallelLedDriver double-buffer alternates and defers the wait") { d.tick(); CHECK(d.activeForTest() == 0); - // The exact call order the base emitted. - REQUIRE(d.calls.size() == 6); - CHECK(d.calls[0].kind == Call::Transmit); CHECK(d.calls[0].buffer == 0); - CHECK(d.calls[1].kind == Call::Transmit); CHECK(d.calls[1].buffer == 1); - CHECK(d.calls[2].kind == Call::Wait); CHECK(d.calls[2].buffer == 0); - CHECK(d.calls[3].kind == Call::Transmit); CHECK(d.calls[3].buffer == 0); - CHECK(d.calls[4].kind == Call::Wait); CHECK(d.calls[4].buffer == 1); - CHECK(d.calls[5].kind == Call::Transmit); CHECK(d.calls[5].buffer == 1); + // The exact call order the orchestrator emitted. + REQUIRE(peripheral.calls.size() == 6); + CHECK(peripheral.calls[0].kind == Call::Transmit); CHECK(peripheral.calls[0].buffer == 0); + CHECK(peripheral.calls[1].kind == Call::Transmit); CHECK(peripheral.calls[1].buffer == 1); + CHECK(peripheral.calls[2].kind == Call::Wait); CHECK(peripheral.calls[2].buffer == 0); + CHECK(peripheral.calls[3].kind == Call::Transmit); CHECK(peripheral.calls[3].buffer == 0); + CHECK(peripheral.calls[4].kind == Call::Wait); CHECK(peripheral.calls[4].buffer == 1); + CHECK(peripheral.calls[5].kind == Call::Transmit); CHECK(peripheral.calls[5].buffer == 1); } // Single-buffer mode (no second buffer): the driver stays on buffer 0 and waits on // it EVERY frame before re-encoding β€” the old synchronous wait-after-transmit path, // so a memory-tight board keeps its old fps rather than failing to init. TEST_CASE("ParallelLedDriver single-buffer mode waits every frame on buffer 0") { - MockParallelDriver d; + mm::ParallelLedDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 64, /*async=*/false); // default: no second buffer, synchronous path + wire(d, peripheral, src, corr, 64, /*async=*/false); // default: no second buffer, synchronous path d.tick(); // tickSync: transmit 0, wait 0 CHECK(d.activeForTest() == 0); @@ -171,15 +169,15 @@ TEST_CASE("ParallelLedDriver single-buffer mode waits every frame on buffer 0") d.tick(); // transmit 0, wait 0 // tickSync waits RIGHT AFTER each transmit (the original synchronous order): T0,W0 Γ—3. - REQUIRE(d.calls.size() == 6); - CHECK(d.calls[0].kind == Call::Transmit); CHECK(d.calls[0].buffer == 0); - CHECK(d.calls[1].kind == Call::Wait); CHECK(d.calls[1].buffer == 0); - CHECK(d.calls[2].kind == Call::Transmit); CHECK(d.calls[2].buffer == 0); - CHECK(d.calls[3].kind == Call::Wait); CHECK(d.calls[3].buffer == 0); - CHECK(d.calls[4].kind == Call::Transmit); CHECK(d.calls[4].buffer == 0); - CHECK(d.calls[5].kind == Call::Wait); CHECK(d.calls[5].buffer == 0); + REQUIRE(peripheral.calls.size() == 6); + CHECK(peripheral.calls[0].kind == Call::Transmit); CHECK(peripheral.calls[0].buffer == 0); + CHECK(peripheral.calls[1].kind == Call::Wait); CHECK(peripheral.calls[1].buffer == 0); + CHECK(peripheral.calls[2].kind == Call::Transmit); CHECK(peripheral.calls[2].buffer == 0); + CHECK(peripheral.calls[3].kind == Call::Wait); CHECK(peripheral.calls[3].buffer == 0); + CHECK(peripheral.calls[4].kind == Call::Transmit); CHECK(peripheral.calls[4].buffer == 0); + CHECK(peripheral.calls[5].kind == Call::Wait); CHECK(peripheral.calls[5].buffer == 0); // Buffer 1 is never allocated or touched. - for (const auto& c : d.calls) CHECK(c.buffer == 0); + for (const auto& c : peripheral.calls) CHECK(c.buffer == 0); } // doubleBuffer is the on/off knob AND drives allocation: OFF (default) allocates ONE buffer and @@ -188,63 +186,95 @@ TEST_CASE("ParallelLedDriver single-buffer mode waits every frame on buffer 0") // it off never holds the second buffer. This mirrors the live toggle (the A/B knob), which routes // through applyState()/prepare() the same way. TEST_CASE("ParallelLedDriver doubleBuffer toggles allocation and path") { - MockParallelDriver d; + mm::ParallelLedDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 64, /*async=*/false); + wire(d, peripheral, src, corr, 64, /*async=*/false); // OFF: single buffer only β€” buffer 1 was never allocated, and tick runs synchronous. - CHECK(d.busBuffer(1) == nullptr); + CHECK(peripheral.busBuffer(1) == nullptr); d.tick(); - REQUIRE(d.calls.size() == 2); // T0, W0 β€” synchronous - CHECK(d.calls[0].kind == Call::Transmit); - CHECK(d.calls[1].kind == Call::Wait); + REQUIRE(peripheral.calls.size() == 2); // T0, W0 β€” synchronous + CHECK(peripheral.calls[0].kind == Call::Transmit); + CHECK(peripheral.calls[1].kind == Call::Wait); // Flip ON and re-prepare (what a live control change does): the second buffer is now allocated. d.doubleBuffer = true; d.applyState(); - CHECK(d.busBuffer(1) != nullptr); - d.calls.clear(); + CHECK(peripheral.busBuffer(1) != nullptr); + peripheral.calls.clear(); d.tick(); // async: transmit 0, no wait, flip to 1 d.tick(); // async: transmit 1, no wait, flip to 0 CHECK(d.activeForTest() == 0); // Two transmits, no interleaved wait (both buffers idle at start) β€” the deferred-wait pattern. - REQUIRE(d.calls.size() == 2); - CHECK(d.calls[0].kind == Call::Transmit); CHECK(d.calls[0].buffer == 0); - CHECK(d.calls[1].kind == Call::Transmit); CHECK(d.calls[1].buffer == 1); + REQUIRE(peripheral.calls.size() == 2); + CHECK(peripheral.calls[0].kind == Call::Transmit); CHECK(peripheral.calls[0].buffer == 0); + CHECK(peripheral.calls[1].kind == Call::Transmit); CHECK(peripheral.calls[1].buffer == 1); // Flip back OFF and re-prepare: the second buffer is freed, back to synchronous. d.doubleBuffer = false; d.applyState(); - CHECK(d.busBuffer(1) == nullptr); + CHECK(peripheral.busBuffer(1) == nullptr); +} + +// Regression (MoonI80 double-buffer freeze): a peripheral that reports supportsDoubleBuffer()==false +// must run SINGLE-buffer even when the doubleBuffer control is ON β€” its own-GDMA two-buffer completion +// handshake races and wedges the bus (the ~200 ms-per-frame freeze). The orchestrator gates the +// second-buffer request on supportsDoubleBuffer(), so the peripheral never gets a second buffer and the +// tick stays on the proven synchronous path. The saved doubleBuffer value is preserved (it just doesn't +// engage here), so switching to a peripheral that DOES support it restores the async behavior. +TEST_CASE("ParallelLedDriver: a peripheral that can't double-buffer stays single-buffer with doubleBuffer ON") { + mm::ParallelLedDriver d; + MockPeripheral peripheral; + mm::Buffer src; + mm::Correction corr; + peripheral.canDoubleBuffer = false; // the MoonI80 case: supportsDoubleBuffer() == false + wire(d, peripheral, src, corr, 64, /*async=*/true); // doubleBuffer ON, but the peripheral refuses async + + // The second buffer is NEVER requested (busInit got wantSecond=false), so it isn't allocated... + CHECK(peripheral.busBuffer(1) == nullptr); + // ...and the saved control value is untouched (survives a round-trip through this peripheral). + CHECK(d.doubleBuffer == true); + + // The tick runs the SYNCHRONOUS single-buffer path: transmit 0 then wait 0, every frame, on buffer 0. + d.tick(); + d.tick(); + for (const auto& c : peripheral.calls) CHECK(c.buffer == 0); // buffer 1 never touched + REQUIRE(peripheral.calls.size() == 4); + CHECK(peripheral.calls[0].kind == Call::Transmit); CHECK(peripheral.calls[1].kind == Call::Wait); + CHECK(peripheral.calls[2].kind == Call::Transmit); CHECK(peripheral.calls[3].kind == Call::Wait); + CHECK(d.activeForTest() == 0); // never flips β€” single-buffer stays on 0 } // A board that WANTS async but can't fit the second buffer (memory-tight) degrades to single-buffer // synchronous β€” never fails to init. doubleBuffer is on, but the mock refuses the second buffer. TEST_CASE("ParallelLedDriver async degrades to synchronous when second buffer won't fit") { - MockParallelDriver d; + mm::ParallelLedDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 64, /*async=*/true, /*canSecond=*/false); - CHECK(d.busBuffer(1) == nullptr); // requested but didn't fit + wire(d, peripheral, src, corr, 64, /*async=*/true, /*canSecond=*/false); + CHECK(peripheral.busBuffer(1) == nullptr); // requested but didn't fit d.tick(); - REQUIRE(d.calls.size() == 2); // synchronous path (T0, W0) - CHECK(d.calls[0].kind == Call::Transmit); - CHECK(d.calls[1].kind == Call::Wait); + REQUIRE(peripheral.calls.size() == 2); // synchronous path (T0, W0) + CHECK(peripheral.calls[0].kind == Call::Transmit); + CHECK(peripheral.calls[1].kind == Call::Wait); } // The frameTime KPI: tick1s() pulls the platform's measured wire time via busLastTransmitUs(). The // string formatting + the actual DMA timing are verified on hardware (the metric's whole point is a // real wire measurement); here we just pin that tick1s reads the seam without crashing pre-first-frame. TEST_CASE("ParallelLedDriver frameTime tick1s is safe before the first transfer") { - MockParallelDriver d; + mm::ParallelLedDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 64, /*async=*/true); + wire(d, peripheral, src, corr, 64, /*async=*/true); d.tick1s(); // lastTransmitUs == 0 β†’ placeholder path, must not divide by zero - d.lastTransmitUs = 7680; // the 256-light WS2812 floor + peripheral.lastTransmitUs = 7680; // the 256-light WS2812 floor d.tick1s(); // real path (1e6/7680 = 130 fps) β€” must not crash - CHECK(d.busLastTransmitUs() == 7680); + CHECK(peripheral.busLastTransmitUs() == 7680); } // Robustness + no-caps (regression for a live bootloop): a correction can carry ANY channel count @@ -254,7 +284,9 @@ TEST_CASE("ParallelLedDriver frameTime tick1s is safe before the first transfer" // driver must DRIVE it (size the frame + encode), not idle and not crash. TEST_CASE("ParallelLedDriver drives an N-channel (>4) correction without overflow") { using R = mm::ChannelRole; - MockParallelDriver d; + mm::ParallelLedDriver d; + MockPeripheral peripheral; + d.setPeripheralForTest(&peripheral); mm::Buffer src; mm::Correction corr; d.doubleBuffer = true; @@ -273,16 +305,16 @@ TEST_CASE("ParallelLedDriver drives an N-channel (>4) correction without overflo // frameBytes scales with 8). The encode runs and transmits; the ASan/valgrind-clean run (and the // hardware regression on the SE16) is the overflow proof β€” a 4-byte stride would have corrupted // memory here. - CHECK(d.severity() != MockParallelDriver::Severity::Error); + CHECK(d.severity() != mm::ParallelLedDriver::Severity::Error); CHECK(d.frameBytes() > 0); // The status reports the total channel count for a multi-channel fixture (lights Γ— channels) β€” // the DMX-universe footprint the user sizes against, not just the light count. CHECK(std::string(d.status()).find("(") != std::string::npos); // "... (N channels)" CHECK(std::string(d.status()).find("channels") != std::string::npos); - d.calls.clear(); + peripheral.calls.clear(); d.tick(); - REQUIRE(d.calls.size() >= 1); - CHECK(d.calls[0].kind == Call::Transmit); // it actually drove the fixture + REQUIRE(peripheral.calls.size() >= 1); + CHECK(peripheral.calls[0].kind == Call::Transmit); // it actually drove the fixture } // A reinit (grid resize / pin edit) must drain BOTH buffers' in-flight transfers @@ -290,16 +322,17 @@ TEST_CASE("ParallelLedDriver drives an N-channel (>4) correction without overflo // use-after-free. After two ticks both buffers are in flight; the resize's reinit // waits on both before rebuilding. (async on β†’ two buffers.) TEST_CASE("ParallelLedDriver reinit drains both in-flight buffers") { - MockParallelDriver d; + mm::ParallelLedDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 64, /*async=*/true); + wire(d, peripheral, src, corr, 64, /*async=*/true); d.tick(); // transmit 0 (in flight) d.tick(); // transmit 1 (in flight) CHECK(d.inFlightForTest(0) == true); CHECK(d.inFlightForTest(1) == true); - const size_t before = d.calls.size(); + const size_t before = peripheral.calls.size(); // Force a rebuild by growing the grid, which changes frameBytes β†’ reinit(). REQUIRE(src.allocate(256, 3) == true); @@ -308,9 +341,9 @@ TEST_CASE("ParallelLedDriver reinit drains both in-flight buffers") { // Both buffers were waited on during the drain (order-independent β€” assert both present). bool waited0 = false, waited1 = false; - for (size_t i = before; i < d.calls.size(); i++) { - if (d.calls[i].kind == Call::Wait && d.calls[i].buffer == 0) waited0 = true; - if (d.calls[i].kind == Call::Wait && d.calls[i].buffer == 1) waited1 = true; + for (size_t i = before; i < peripheral.calls.size(); i++) { + if (peripheral.calls[i].kind == Call::Wait && peripheral.calls[i].buffer == 0) waited0 = true; + if (peripheral.calls[i].kind == Call::Wait && peripheral.calls[i].buffer == 1) waited1 = true; } CHECK(waited0); CHECK(waited1); @@ -326,32 +359,33 @@ TEST_CASE("ParallelLedDriver reinit drains both in-flight buffers") { // inFlight_ either way; πŸ‡ CodeRabbit caught it.) The contract now: on timeout the buffer STAYS // in-flight, the frame is skipped, and the driver re-waits next tick β€” self-healing, never corrupting. TEST_CASE("ParallelLedDriver: a timed-out wait never re-encodes into the live buffer") { - MockParallelDriver d; + mm::ParallelLedDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 64, /*async=*/true); + wire(d, peripheral, src, corr, 64, /*async=*/true); d.tick(); // encode+transmit buffer 0 β†’ in flight d.tick(); // encode+transmit buffer 1 β†’ in flight; next tick must reuse buffer 0 REQUIRE(d.inFlightForTest(0) == true); - d.waitTimesOut = true; // buffer 0's transfer is wedged (the DMA never completes) - const size_t before = d.calls.size(); + peripheral.waitTimesOut = true; // buffer 0's transfer is wedged (the DMA never completes) + const size_t before = peripheral.calls.size(); d.tick(); // must NOT encode/transmit into buffer 0 // It waited on 0 and then gave up β€” no Encode, no Transmit followed. bool transmitted = false; - for (size_t i = before; i < d.calls.size(); i++) - if (d.calls[i].kind == Call::Transmit) transmitted = true; + for (size_t i = before; i < peripheral.calls.size(); i++) + if (peripheral.calls[i].kind == Call::Transmit) transmitted = true; CHECK_FALSE(transmitted); // the live buffer was NOT reused CHECK(d.inFlightForTest(0) == true); // still marked in flight, so the next tick re-waits // The DMA completes: the very next tick proceeds normally β€” it self-heals, no reinit needed. - d.waitTimesOut = false; + peripheral.waitTimesOut = false; d.tick(); bool transmittedNow = false; - for (size_t i = before; i < d.calls.size(); i++) - if (d.calls[i].kind == Call::Transmit) transmittedNow = true; + for (size_t i = before; i < peripheral.calls.size(); i++) + if (peripheral.calls[i].kind == Call::Transmit) transmittedNow = true; CHECK(transmittedNow); } @@ -362,29 +396,30 @@ TEST_CASE("ParallelLedDriver: a timed-out wait never re-encodes into the live bu // control. So once given up, the driver periodically lets one frame through; if the bus is alive again, // output resumes on its own. This pins that retry-recovery. TEST_CASE("ParallelLedDriver: give-up self-recovers on a periodic retry, no reinit needed") { - MockParallelDriver d; + mm::ParallelLedDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 64, /*async=*/true); + wire(d, peripheral, src, corr, 64, /*async=*/true); // Wedge the bus and tick until it gives up: every wait times out, so each frame is a dead strike. - d.waitTimesOut = true; + peripheral.waitTimesOut = true; for (int i = 0; i < 40; i++) d.tick(); // well past kDeadFramesBeforeGiveUp CHECK(d.severity() == mm::DriverBase::Severity::Error); // reported "output stalled" // While given up, a tick must NOT transmit every frame (that would keep spending the render thread). - size_t mark = d.calls.size(); + size_t mark = peripheral.calls.size(); d.tick(); bool transmittedWhileGivenUp = false; - for (size_t i = mark; i < d.calls.size(); i++) - if (d.calls[i].kind == Call::Transmit) transmittedWhileGivenUp = true; + for (size_t i = mark; i < peripheral.calls.size(); i++) + if (peripheral.calls[i].kind == Call::Transmit) transmittedWhileGivenUp = true; CHECK_FALSE(transmittedWhileGivenUp); // this tick was inside the quiet window, not a retry // The bus comes back to life. Within one retry window the driver lets a frame through, it completes, // and output resumes β€” no config change, no reinit. Recovery means the driver actually LEFT the // give-up state (its Error status cleared), not merely that one retry Transmit happened: a retry that // transmits but whose wait still fails would keep the driver given-up, and that must NOT count. - d.waitTimesOut = false; + peripheral.waitTimesOut = false; bool recovered = false; for (int i = 0; i < 200 && !recovered; i++) { // several retry windows' worth of margin d.tick(); @@ -393,10 +428,10 @@ TEST_CASE("ParallelLedDriver: give-up self-recovers on a periodic retry, no rein CHECK(recovered); // the give-up Error state cleared β€” the driver is transmitting normally again // And it keeps transmitting on the following ticks (steady-state, not a one-off retry blip). - const size_t after = d.calls.size(); + const size_t after = peripheral.calls.size(); d.tick(); bool stillTransmitting = false; - for (size_t j = after; j < d.calls.size(); j++) - if (d.calls[j].kind == Call::Transmit) stillTransmitting = true; + for (size_t j = after; j < peripheral.calls.size(); j++) + if (peripheral.calls[j].kind == Call::Transmit) stillTransmitting = true; CHECK(stillTransmitting); } diff --git a/test/unit/light/unit_ParallelLedDriver_pinexpander.cpp b/test/unit/light/unit_ParallelLedDriver_pinexpander.cpp index f49fd987..8d7349b3 100644 --- a/test/unit/light/unit_ParallelLedDriver_pinexpander.cpp +++ b/test/unit/light/unit_ParallelLedDriver_pinexpander.cpp @@ -22,32 +22,66 @@ namespace { using mm::nrOfLightsType; -// A mock parallel driver whose "bus" is plain memory (same shape as the double-buffer mock), -// so the lane/frame arithmetic is provable on the host with no peripheral. -class MockShiftDriver : public mm::ParallelLedDriver { +// A mock peripheral backend whose "bus" is plain memory (same shape as the double-buffer mock's +// MockPeripheral in unit_ParallelLedDriver_doublebuffer.cpp), so the lane/frame arithmetic is +// provable on the host with no real i80/Parlio/MoonI80 peripheral. Models the i80 SHAPE +// (powerOfTwoBus true β€” the expander only ever runs on an i80-shaped bus). +class MockPeripheral : public mm::LedPeripheral { public: - static constexpr uint8_t lanesAvailable() { return 8; } // 8 data lines, like an 8-bit bus - // The i80 shape: the BUS rounds to 8/16 whatever the pin count, so the driver pads the lane list. - // (Parlio, the other shape, sets this false β€” its bus width IS the pin count.) The expander only - // ever runs on an i80-shaped bus, so the mock models that one. - static constexpr bool kPowerOfTwoBus = true; - static constexpr bool kLoopbackFullWidth = false; - static constexpr bool kSupportsPinExpander = true; // memory bus: the expander is allowed - static constexpr const char* kInitFailMsg = "mock init failed"; - - void addBusControls() {} - bool busControlTriggersBuild(const char*) const { return false; } - void recordBusPins() {} - bool extraBusPinsCurrent() const { return true; } - const char* validateBusPins(const uint16_t*, uint8_t) const { return nullptr; } - const char* validateBusFatal() const { return nullptr; } - - bool busInit(size_t frameBytes, bool) { + uint8_t lanesAvailable() const override { return 8; } // 8 data lines, like an 8-bit bus + bool powerOfTwoBus() const override { return true; } // the BUS rounds to 8/16 whatever the pin count + bool loopbackFullWidth() const override { return false; } + bool supportsPinExpander() const override { return true; } // memory bus: the expander is allowed + const char* initFailMsg() const override { return "mock init failed"; } + mm::LedHwBlock hwBlock() const override { return mm::LedHwBlock::None; } // mock drives no real block + + void addBusControls(mm::ControlList&) override {} + bool busControlTriggersBuild(const char*) const override { return false; } + void recordBusPins() override {} + bool extraBusPinsCurrent() const override { return true; } + const char* validateBusPins(const uint16_t*, uint8_t) const override { return nullptr; } + const char* validateBusFatal() const override { return nullptr; } + uint16_t clockPinForBus() const override { return 99; } // a recognizable "parked here" sentinel + + bool busInit(size_t frameBytes, bool) override { cap_ = frameBytes; buf_.assign(frameBytes, 0); return true; } + uint8_t* busBuffer(uint8_t i) override { return (i == 0 && !buf_.empty()) ? buf_.data() : nullptr; } + size_t busCapacity() const override { return cap_; } + // busTransmit models the real enqueue/completion split: it reports success for the ENQUEUE (like + // esp_lcd's tx_color returning ESP_OK the moment the transfer is queued), which does NOT mean the + // frame reached the wire β€” completion is a separate signal via busWait below. So a true here is + // "queued", not "done". + bool busTransmit(uint8_t, size_t) override { transmits_++; return true; } + // busWait is the completion half: `waitTimesOut` models a transfer whose done-signal never fires + // (a stuck/failed DMA). The driver must degrade, not wedge, and must not reuse the buffer while a + // transfer may still be reading it. + bool busWait(uint8_t, uint32_t) override { return !waitTimesOut; } + bool waitTimesOut = false; + uint32_t busLastTransmitUs() const override { return 0; } + void busDeinit() override { cap_ = 0; buf_.clear(); } + mm::platform::RmtLoopbackResult busLoopback(const uint8_t*, size_t, size_t, uint8_t) override { + return {}; + } + + size_t transmitCount() const { return transmits_; } + +private: + std::vector buf_; + size_t cap_ = 0; + size_t transmits_ = 0; +}; +// A tiny ParallelLedDriver subclass that exposes the protected lane-math internals a test needs +// (physPins_, encodeRows) without widening the production class's public surface β€” same pattern as +// unit_MultiPinLedDriver.cpp's `Expose : mm::ParallelLedDriver` for frameFitsDmaBudget. Everything +// else the tests use (laneCount, maxLaneLights, frameBytes, busPinList/busPinCount, pins, +// ledsPerPin, pinExpander, latchPin, defineControls, setSourceBuffer, correctionForTest, +// applyState, severity, status) is already public on ParallelLedDriver itself. +class MockShiftDriver : public mm::ParallelLedDriver { +public: // The streaming ring encodes the frame one SLICE at a time, straight into the small internal // buffer the DMA is about to read. That is only sound if a sliced encode produces byte-identical // output to the whole-frame encode β€” so expose the encoder for the test that pins it. @@ -56,45 +90,28 @@ class MockShiftDriver : public mm::ParallelLedDriver { mm::nrOfLightsType count, bool closeFrame) { this->template encodeRows(outCh, dst, first, count, closeFrame); } - uint8_t* busBuffer(uint8_t i) { return (i == 0 && !buf_.empty()) ? buf_.data() : nullptr; } - size_t busCapacity() const { return cap_; } - // busTransmit reports success β€” as the real one does. This is the crux of the 2026-07-14 bug: - // esp_lcd's tx_color returns ESP_OK because the ENQUEUE succeeded, while the GDMA mount fails - // later inside the ISR. So "transmit returned true" does NOT mean the frame reached the wire. - bool busTransmit(uint8_t, size_t) { transmits_++; return true; } - // `waitTimesOut` simulates a transfer whose done-callback never fires β€” precisely what a failed - // GDMA mount produces. The driver must degrade, not wedge, and must not reuse the buffer. - bool busWait(uint8_t, uint32_t) { return !waitTimesOut; } - bool waitTimesOut = false; - uint32_t busLastTransmitUs() const { return 0; } - void busDeinit() { cap_ = 0; buf_.clear(); } - mm::platform::RmtLoopbackResult busLoopback(const uint8_t*, size_t, size_t, uint8_t) { - return {}; - } - // Test-only view of the derived lane math. uint8_t physPinsForTest() const { return physPins_; } - size_t transmitCount() const { return transmits_; } // The GPIO list + length handed to the PERIPHERAL β€” the two must agree, or the platform reads off - // the end of the array. See the padding test below. + // the end of the array. See the padding test below. (busPinList/busPinCount are already public.) const uint16_t* busPinListForTest() { return this->busPinList(); } uint8_t busPinCountForTest() const { return this->busPinCount(); } - uint16_t clockPinForBus() const { return 99; } // a recognisable "parked here" sentinel - -private: - std::vector buf_; - size_t cap_ = 0; - size_t transmits_ = 0; }; // Bring a mock driver up on `lights` lights with the given pin list and expander setting. // shiftOn fits the 74HCT595 expander (8 strands per pin); latch is the latch GPIO. -void wire(MockShiftDriver& d, mm::Buffer& src, mm::Correction& corr, nrOfLightsType lights, - const char* pins, bool shiftOn, int8_t latch, const char* ledsPerPin = "") { +void wire(MockShiftDriver& d, MockPeripheral& peripheral, mm::Buffer& src, mm::Correction& corr, + nrOfLightsType lights, const char* pins, bool shiftOn, int8_t latch, + const char* ledsPerPin = "") { + d.setPeripheralForTest(&peripheral); std::strcpy(d.pins, pins); std::strcpy(d.ledsPerPin, ledsPerPin); d.pinExpander = shiftOn; d.latchPin = latch; + // These tests exercise the SINGLE-buffer expander/ring path. MockPeripheral::busInit ignores the + // double-buffer request (busBuffer(1) is always null), so run the driver in single-buffer mode + // EXPLICITLY rather than relying on a silent fallback β€” the coverage is honest about which path runs. + d.doubleBuffer = false; REQUIRE(src.allocate(lights, 3) == (lights > 0)); mm::test::rebuildFromPreset(corr, 255, mm::test::PresetOrder::GRB); d.defineControls(); @@ -109,10 +126,11 @@ void wire(MockShiftDriver& d, mm::Buffer& src, mm::Correction& corr, nrOfLightsT // the whole point of the expander (pins are the scarce resource, not strands). TEST_CASE("shift register: lanes = pins x 8") { MockShiftDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; // 6 data pins + a latch β†’ 48 strands, the PO's 48x256 panel. - wire(d, src, corr, 48 * 256, "1,2,3,4,5,6", /*shiftOn=*/true, /*latch=*/7); + wire(d, peripheral, src, corr, 48 * 256, "1,2,3,4,5,6", /*shiftOn=*/true, /*latch=*/7); CHECK(d.physPinsForTest() == 6); CHECK(d.laneCount() == 48); // 6 pins x 8 outputs @@ -128,10 +146,11 @@ TEST_CASE("shift register: lanes = pins x 8") { // furthest read is < 1920. TEST_CASE("shift register: driver clamps to pins x ledsPerPin, ignoring a larger layout") { MockShiftDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; // Source is 1920 lights (a 5x3 grid of 8x16 panels); the driver only drives 2 pins x 8 x 60 = 960. - wire(d, src, corr, /*lights=*/1920, "9,10", /*shiftOn=*/true, /*latch=*/11, /*ledsPerPin=*/"60"); + wire(d, peripheral, src, corr, /*lights=*/1920, "9,10", /*shiftOn=*/true, /*latch=*/11, /*ledsPerPin=*/"60"); CHECK(d.laneCount() == 16); // 2 pins x 8 outputs CHECK(d.maxLaneLights() == 60); // clamped to ledsPerPin, NOT the 1920/16 the source could feed @@ -155,9 +174,10 @@ TEST_CASE("shift register: driver clamps to pins x ledsPerPin, ignoring a larger // the expander must not have altered the existing behaviour. TEST_CASE("shift register: direct mode still drives one strand per pin") { MockShiftDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 4 * 256, "1,2,3,4", /*shiftOn=*/false, /*latch=*/-1); + wire(d, peripheral, src, corr, 4 * 256, "1,2,3,4", /*shiftOn=*/false, /*latch=*/-1); CHECK(d.physPinsForTest() == 4); CHECK(d.laneCount() == 4); // no fan-out @@ -174,15 +194,17 @@ TEST_CASE("shift register: frame is set by strand LENGTH and the fan-out, not by // 2 pins x 8 = 16 strands, 256 lights each. MockShiftDriver small; + MockPeripheral peripheralSmall; mm::Buffer srcSmall; - wire(small, srcSmall, corr, 16 * 256, "1,2", /*shiftOn=*/true, /*latch=*/3); + wire(small, peripheralSmall, srcSmall, corr, 16 * 256, "1,2", /*shiftOn=*/true, /*latch=*/3); CHECK(small.laneCount() == 16); CHECK(small.maxLaneLights() == 256); // 6 pins x 8 = 48 strands, still 256 lights each β€” 3x the strands, 3x the lights. MockShiftDriver big; + MockPeripheral peripheralBig; mm::Buffer srcBig; - wire(big, srcBig, corr, 48 * 256, "1,2,3,4,5,6", /*shiftOn=*/true, /*latch=*/7); + wire(big, peripheralBig, srcBig, corr, 48 * 256, "1,2,3,4,5,6", /*shiftOn=*/true, /*latch=*/7); CHECK(big.laneCount() == 48); CHECK(big.maxLaneLights() == 256); @@ -197,15 +219,17 @@ TEST_CASE("shift register: the fan-out costs 8x the DMA frame") { // 8 strands, 256 lights each β€” direct (8 pins, one strand each). MockShiftDriver direct; + MockPeripheral peripheralDirect; mm::Buffer srcDirect; - wire(direct, srcDirect, corr, 8 * 256, "1,2,3,4,5,6,7,8", /*shiftOn=*/false, /*latch=*/-1); + wire(direct, peripheralDirect, srcDirect, corr, 8 * 256, "1,2,3,4,5,6,7,8", /*shiftOn=*/false, /*latch=*/-1); CHECK(direct.laneCount() == 8); CHECK(direct.maxLaneLights() == 256); // The same 8 strands at the same length, but through ONE pin's '595. MockShiftDriver shifted; + MockPeripheral peripheralShifted; mm::Buffer srcShifted; - wire(shifted, srcShifted, corr, 8 * 256, "1", /*shiftOn=*/true, /*latch=*/2); + wire(shifted, peripheralShifted, srcShifted, corr, 8 * 256, "1", /*shiftOn=*/true, /*latch=*/2); CHECK(shifted.laneCount() == 8); CHECK(shifted.maxLaneLights() == 256); @@ -224,9 +248,10 @@ TEST_CASE("shift register: the fan-out costs 8x the DMA frame") { // encode 16-bit slots into an 8-bit bus and emit a frame of pure garbage. TEST_CASE("shift register: 48 strands on 6 pins is still an 8-bit bus") { MockShiftDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 48 * 256, "1,2,3,4,5,6", /*shiftOn=*/true, /*latch=*/7); + wire(d, peripheral, src, corr, 48 * 256, "1,2,3,4,5,6", /*shiftOn=*/true, /*latch=*/7); CHECK(d.laneCount() == 48); // ...strands, but // frameBytes = rows x channels x 24 slots x slotBytes x 8 (+ pad). With an 8-bit @@ -241,11 +266,12 @@ TEST_CASE("shift register: 48 strands on 6 pins is still an 8-bit bus") { // that lane would carry the latch waveform instead of pixel data. A config error, not a crash. TEST_CASE("shift register: latchPin colliding with a data pin is a config error") { MockShiftDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 8 * 256, "1,2,3,4", /*shiftOn=*/true, /*latch=*/3); // 3 is a data pin + wire(d, peripheral, src, corr, 8 * 256, "1,2,3,4", /*shiftOn=*/true, /*latch=*/3); // 3 is a data pin - CHECK(d.severity() == MockShiftDriver::Severity::Error); + CHECK(d.severity() == mm::MoonModule::Severity::Error); CHECK(d.laneCount() == 0); // idles rather than driving a broken bus } @@ -253,27 +279,29 @@ TEST_CASE("shift register: latchPin colliding with a data pin is a config error" // config rather than run a bus that silently outputs nothing. TEST_CASE("shift register: the expander needs a latchPin") { MockShiftDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 8 * 256, "1,2,3,4", /*shiftOn=*/true, /*latch=*/-1); // unset + wire(d, peripheral, src, corr, 8 * 256, "1,2,3,4", /*shiftOn=*/true, /*latch=*/-1); // unset - CHECK(d.severity() == MockShiftDriver::Severity::Error); + CHECK(d.severity() == mm::MoonModule::Severity::Error); CHECK(d.laneCount() == 0); } // The latch must not land on the peripheral's own WR/DC pins either β€” bench-found, because WR // defaults to GPIO 10 and that is the first free-looking pin a user reaches for. The i80 bus builds // fine, so the failure is silent garbage on the strands rather than an init error; that is what makes -// it worth an explicit guard. (The check itself lives in MultiPinLedDriver::validateBusFatal, which the +// it worth an explicit guard. (The check itself lives in I80Peripheral::validateBusFatal, which the // mock does not have β€” this pins the base's half: a data-pin collision is caught, so the mechanism -// is live. The WR/DC half is a compile-time-visible guard in the i80 driver.) +// is live. The WR/DC half is a compile-time-visible guard in the i80 backend.) TEST_CASE("shift register: driver refuses a latch that collides with a data lane") { MockShiftDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; // Latch on data pin 2 β†’ the lane would carry the latch waveform instead of pixels. - wire(d, src, corr, 8 * 256, "1,2,3", /*shiftOn=*/true, /*latch=*/2); - CHECK(d.severity() == MockShiftDriver::Severity::Error); + wire(d, peripheral, src, corr, 8 * 256, "1,2,3", /*shiftOn=*/true, /*latch=*/2); + CHECK(d.severity() == mm::MoonModule::Severity::Error); CHECK(d.laneCount() == 0); } @@ -286,12 +314,14 @@ TEST_CASE("shift register: the loopback test frame is 8x the direct-mode frame") mm::Correction corr; MockShiftDriver direct; + MockPeripheral peripheralDirect; mm::Buffer srcDirect; - wire(direct, srcDirect, corr, 8 * 64, "1,2,3,4,5,6,7,8", /*shiftOn=*/false, /*latch=*/-1); + wire(direct, peripheralDirect, srcDirect, corr, 8 * 64, "1,2,3,4,5,6,7,8", /*shiftOn=*/false, /*latch=*/-1); MockShiftDriver shifted; + MockPeripheral peripheralShifted; mm::Buffer srcShifted; - wire(shifted, srcShifted, corr, 8 * 64, "1", /*shiftOn=*/true, /*latch=*/2); + wire(shifted, peripheralShifted, srcShifted, corr, 8 * 64, "1", /*shiftOn=*/true, /*latch=*/2); // Same strands, same length; the shift frame pays 8 bus words per slot instead of 1. frameBytes() // is the operational frame, which the loopback's per-light sizing mirrors (both scale by @@ -303,9 +333,10 @@ TEST_CASE("shift register: the loopback test frame is 8x the direct-mode frame") // reconfigure cleanly each time, never wedge or leave stale lane state behind. TEST_CASE("shift register: toggling the expander live reconfigures cleanly") { MockShiftDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 8 * 256, "1,2,3,4", /*shiftOn=*/false, /*latch=*/-1); + wire(d, peripheral, src, corr, 8 * 256, "1,2,3,4", /*shiftOn=*/false, /*latch=*/-1); CHECK(d.laneCount() == 4); const size_t directFrame = d.frameBytes(); @@ -321,7 +352,7 @@ TEST_CASE("shift register: toggling the expander live reconfigures cleanly") { d.applyState(); CHECK(d.laneCount() == 4); CHECK(d.frameBytes() == directFrame); - CHECK(d.severity() != MockShiftDriver::Severity::Error); + CHECK(d.severity() != mm::MoonModule::Severity::Error); } // =========================================================================== @@ -346,19 +377,20 @@ TEST_CASE("shift register: toggling the expander live reconfigures cleanly") { // inside a timing-out wait. It must stay responsive, and it must not corrupt the in-flight buffer. TEST_CASE("shift register: a transfer that never completes does not wedge the driver") { MockShiftDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 16 * 256, "1,2", /*shiftOn=*/true, /*latch=*/3); + wire(d, peripheral, src, corr, 16 * 256, "1,2", /*shiftOn=*/true, /*latch=*/3); REQUIRE(d.laneCount() == 16); - d.waitTimesOut = true; // the DMA never signals done β€” exactly the bench failure + peripheral.waitTimesOut = true; // the DMA never signals done β€” exactly the bench failure // Tick repeatedly. The driver must keep running (no hang, no crash) and must NOT report success. for (int i = 0; i < 5; i++) d.tick(); // It must still be alive and configured β€” degraded, not dead. CHECK(d.laneCount() == 16); - CHECK(d.severity() != MockShiftDriver::Severity::Error); + CHECK(d.severity() != mm::MoonModule::Severity::Error); } // The buffer-safety invariant the timeout exists to protect: while a transfer may still be reading a @@ -366,18 +398,31 @@ TEST_CASE("shift register: a transfer that never completes does not wedge the dr // frame and half of the next on the wire β€” the "scattered random pixels" seen on the bench. TEST_CASE("shift register: a timed-out transfer never gets its buffer re-encoded") { MockShiftDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 16 * 256, "1,2", /*shiftOn=*/true, /*latch=*/3); + wire(d, peripheral, src, corr, 16 * 256, "1,2", /*shiftOn=*/true, /*latch=*/3); - d.waitTimesOut = true; + peripheral.waitTimesOut = true; d.tick(); // starts a transfer that will never complete - const size_t transmitsAfterFirst = d.transmitCount(); + const size_t transmitsAfterFirst = peripheral.transmitCount(); - d.tick(); // must NOT transmit again over the live buffer + // Snapshot the buffer the stuck transfer is (conceptually) still reading. The immutability contract + // is not just "no new transmit" but "no re-encode": the bytes must be byte-for-byte unchanged while + // the transfer is in flight, or half of one frame + half of the next reach the wire (the bench's + // "scattered random pixels"). Read through busBuffer(0) β€” the same memory the encoder writes. + const uint8_t* live = peripheral.busBuffer(0); + REQUIRE(live != nullptr); + const std::vector snapshot(live, live + peripheral.busCapacity()); + + d.tick(); // must NOT transmit again NOR re-encode over the live buffer d.tick(); - CHECK(d.transmitCount() == transmitsAfterFirst); // no new transfer while the old one is stuck + CHECK(peripheral.transmitCount() == transmitsAfterFirst); // no new transfer while the old one is stuck + // And the buffer contents are untouched β€” the stronger invariant the timeout exists to protect. + const uint8_t* after = peripheral.busBuffer(0); + REQUIRE(after != nullptr); + CHECK(std::vector(after, after + peripheral.busCapacity()) == snapshot); } // **The robustness rule: a broken bus must not cost the user the DEVICE.** Every dead transfer is a @@ -390,42 +435,44 @@ TEST_CASE("shift register: a timed-out transfer never gets its buffer re-encoded // it. Output idle, device alive β€” never the other way round. TEST_CASE("a persistently dead bus is given up on, so it cannot starve the device") { MockShiftDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 16 * 256, "1,2", /*shiftOn=*/true, /*latch=*/3); + wire(d, peripheral, src, corr, 16 * 256, "1,2", /*shiftOn=*/true, /*latch=*/3); - d.waitTimesOut = true; // the DMA never signals done, every frame, forever + peripheral.waitTimesOut = true; // the DMA never signals done, every frame, forever for (int i = 0; i < 40; i++) d.tick(); // It gave up: the failure is REPORTED (the user can see why the LEDs are dark)... - CHECK(d.severity() == MockShiftDriver::Severity::Error); + CHECK(d.severity() == mm::MoonModule::Severity::Error); // ...and it stopped spending the render thread on a bus that will never deliver. The exact count // doesn't matter; what matters is that it is BOUNDED β€” it did not keep trying for all 40 ticks. - CHECK(d.transmitCount() < 40u); + CHECK(peripheral.transmitCount() < 40u); } // Giving up must not be permanent: the user fixes the setting that broke the bus, the driver rebuilds, // and the LEDs come back β€” no reboot (the live-reconfiguration rule). TEST_CASE("a given-up driver recovers when the bus is fixed") { MockShiftDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 16 * 256, "1,2", /*shiftOn=*/true, /*latch=*/3); + wire(d, peripheral, src, corr, 16 * 256, "1,2", /*shiftOn=*/true, /*latch=*/3); - d.waitTimesOut = true; + peripheral.waitTimesOut = true; for (int i = 0; i < 40; i++) d.tick(); - REQUIRE(d.severity() == MockShiftDriver::Severity::Error); // gave up - const size_t transmitsWhileDead = d.transmitCount(); + REQUIRE(d.severity() == mm::MoonModule::Severity::Error); // gave up + const size_t transmitsWhileDead = peripheral.transmitCount(); // The user fixes the config: the bus is rebuilt, and now transfers complete. - d.waitTimesOut = false; + peripheral.waitTimesOut = false; d.applyState(); // prepare -> reinit: a fresh bus deserves a clean slate for (int i = 0; i < 5; i++) d.tick(); - CHECK(d.transmitCount() > transmitsWhileDead); // transmitting again - CHECK(d.severity() != MockShiftDriver::Severity::Error); // and the error is cleared + CHECK(peripheral.transmitCount() > transmitsWhileDead); // transmitting again + CHECK(d.severity() != mm::MoonModule::Severity::Error); // and the error is cleared } // **THE INVARIANT THE STREAMING RING RESTS ON.** The ring never materialises the big encoded frame: @@ -440,8 +487,9 @@ TEST_CASE("a given-up driver recovers when the bus is fixed") { TEST_CASE("streaming ring: a sliced encode is byte-identical to the whole-frame encode") { mm::Correction corr; MockShiftDriver whole; + MockPeripheral peripheral; mm::Buffer srcWhole; - wire(whole, srcWhole, corr, 16 * 32, "1,2", /*shiftOn=*/true, /*latch=*/3); + wire(whole, peripheral, srcWhole, corr, 16 * 32, "1,2", /*shiftOn=*/true, /*latch=*/3); REQUIRE(whole.maxLaneLights() == 32); // Fill the source with a dense, varied pattern β€” a sparse/zero buffer would hide a slicing bug. @@ -489,7 +537,8 @@ TEST_CASE("bus pin list is padded to the full bus width, in both modes") { SUBCASE("direct mode: 3 pins β†’ 8 lanes, 5 parked on the clock pin") { MockShiftDriver d; - wire(d, src, corr, 64, "1,2,4", /*shiftOn=*/false, /*latch=*/-1); + MockPeripheral peripheral; + wire(d, peripheral, src, corr, 64, "1,2,4", /*shiftOn=*/false, /*latch=*/-1); REQUIRE(d.busPinCountForTest() == 8); // the BUS width, not the pin count const uint16_t* list = d.busPinListForTest(); @@ -501,7 +550,8 @@ TEST_CASE("bus pin list is padded to the full bus width, in both modes") { SUBCASE("shift mode: 2 pins + latch β†’ 8 lanes, the rest parked") { MockShiftDriver d; - wire(d, src, corr, 64, "1,2", /*shiftOn=*/true, /*latch=*/7); + MockPeripheral peripheral; + wire(d, peripheral, src, corr, 64, "1,2", /*shiftOn=*/true, /*latch=*/7); REQUIRE(d.busPinCountForTest() == 8); const uint16_t* list = d.busPinListForTest(); @@ -529,9 +579,10 @@ TEST_CASE("bus pin list is padded to the full bus width, in both modes") { // tripping the out-of-memory path. TEST_CASE("shift register: the loopback frame has room for its closing latch word") { MockShiftDriver d; + MockPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 16 * 8, "1,2", /*shiftOn=*/true, /*latch=*/3); + wire(d, peripheral, src, corr, 16 * 8, "1,2", /*shiftOn=*/true, /*latch=*/3); REQUIRE(d.laneCount() == 16); // 2 pins x 8 outputs d.loopbackTest = true; @@ -540,6 +591,32 @@ TEST_CASE("shift register: the loopback frame has room for its closing latch wor d.tick(); // builds + "transmits" the test frame (mock bus) // It must not have fallen into the out-of-memory branch, and must still be a live driver. - CHECK(d.severity() != MockShiftDriver::Severity::Error); + CHECK(d.severity() != mm::MoonModule::Severity::Error); CHECK(d.laneCount() == 16); } + +// A peripheral that CANNOT host the '595 (Parlio's transfer cap, or the classic i80 = I2S). Its +// pinExpander control is hidden, so a user can't turn a stray `pinExpander=true` back off β€” the driver +// must therefore degrade to direct mode, not wedge in an unfixable error status. +struct NoExpanderPeripheral : MockPeripheral { + bool supportsPinExpander() const override { return false; } +}; + +// Switching to (or loading a saved config on) a peripheral that can't host the expander must degrade to +// direct mode β€” because the pinExpander toggle is hidden there, an error status would be unfixable from +// the UI. The degrade is in the EFFECTIVE mode (pinExpanderMode()), NOT a mutation of the stored value: +// the saved `pinExpander=true` is preserved so A/B'ing back to a supporting peripheral restores shift +// mode. Pins: the driver KEEPS pinExpander, drives DIRECT (effective mode off), and reports no error. +TEST_CASE("pinExpander degrades to direct on a peripheral that can't host it, keeping the saved value") { + NoExpanderPeripheral peripheral; // declared before the driver β€” borrowed, must outlive it + MockShiftDriver d; + mm::Buffer src; + mm::Correction corr; + // Ask for the expander (as a stale saved config or a post-switch state would): 3 data pins, latch set. + wire(d, peripheral, src, corr, 3 * 8, "1,2,3", /*shiftOn=*/true, /*latch=*/7); + + CHECK(d.pinExpander); // saved value PRESERVED (not mutated) + CHECK_FALSE(d.pinExpanderMode()); // but the EFFECTIVE mode is direct + CHECK(d.severity() != mm::MoonModule::Severity::Error); // NOT a dead-end error + CHECK(d.laneCount() == 3); // 3 pins β†’ 3 direct lanes (not Γ—8) +} diff --git a/test/unit/light/unit_ParallelLedDriver_ring.cpp b/test/unit/light/unit_ParallelLedDriver_ring.cpp index 22762120..8649b384 100644 --- a/test/unit/light/unit_ParallelLedDriver_ring.cpp +++ b/test/unit/light/unit_ParallelLedDriver_ring.cpp @@ -44,60 +44,55 @@ using mm::nrOfLightsType; constexpr uint8_t kMockRingBufs = 4; constexpr uint32_t kMockRingRows = 16; -class MockRingDriver : public mm::ParallelLedDriver { +class MockRingDriver; // forward decl β€” the peripheral's ring hooks call back into the owner's encode + +// The ring-capable backend: a memory-only LedPeripheral (same shape as unit_ParallelLedDriver_doublebuffer's +// MockPeripheral) PLUS the ring hooks (busInitRing/busTransmitRing/busIsRing/wantsRing), which is what +// MoonI80Peripheral is on real hardware. Holds the ring buffer pool and geometry; the actual per-slice +// encode is the OWNER's (ParallelLedDriver::encodeRows), reached through owner_ exactly as +// MoonI80Peripheral::ringEncodeTrampoline does. +class MockRingPeripheral : public mm::LedPeripheral { public: - static constexpr uint8_t lanesAvailable() { return 8; } // 8 data lines (an 8-bit bus) - static constexpr bool kPowerOfTwoBus = true; - static constexpr bool kLoopbackFullWidth = false; - static constexpr bool kSupportsPinExpander = true; - static constexpr const char* kInitFailMsg = "mock init failed"; - - void addBusControls() {} - // A ring-capable backend adds its ring cluster here (the base's default addRingControls is a no-op for + uint8_t lanesAvailable() const override { return 8; } // 8 data lines (an 8-bit bus) + bool powerOfTwoBus() const override { return true; } + bool loopbackFullWidth() const override { return false; } + bool supportsPinExpander() const override { return true; } + const char* initFailMsg() const override { return "mock init failed"; } + mm::LedHwBlock hwBlock() const override { return mm::LedHwBlock::None; } // mock drives no real block + + void addBusControls(mm::ControlList&) override {} + // A ring-capable backend adds its ring cluster here (the default addRingControls is a no-op for // whole-frame-only backends). Mirror the real driver: the source-snapshot knob under the path, gated // on wantsRing() β€” this mock's controllable wantRing_ drives the visibility the hide test checks. - void addRingControls() { - controls_.addBool("ringSnapshot", ringSnapshot); - controls_.setHidden(controls_.count() - 1, !wantsRing()); + void addRingControls(mm::ControlList& controls) override { + controls.addBool("ringSnapshot", owner_->ringSnapshotRef()); + controls.setHidden(controls.count() - 1, !wantsRing()); } - bool busControlTriggersBuild(const char*) const { return false; } - void recordBusPins() {} - bool extraBusPinsCurrent() const { return true; } - const char* validateBusPins(const uint16_t*, uint8_t) const { return nullptr; } - const char* validateBusFatal() const { return nullptr; } - uint16_t clockPinForBus() const { return 99; } + bool busControlTriggersBuild(const char*) const override { return false; } + void recordBusPins() override {} + bool extraBusPinsCurrent() const override { return true; } + const char* validateBusPins(const uint16_t*, uint8_t) const override { return nullptr; } + const char* validateBusFatal() const override { return nullptr; } + uint16_t clockPinForBus() const override { return 99; } // --- whole-frame bus (used to produce the reference frame the ring output is compared against) --- - bool busInit(size_t frameBytes, bool) { cap_ = frameBytes; buf_.assign(frameBytes, 0); return true; } - uint8_t* busBuffer(uint8_t i) { return (i == 0 && !buf_.empty()) ? buf_.data() : nullptr; } - size_t busCapacity() const { return cap_; } - bool busTransmit(uint8_t, size_t) { return true; } - bool busWait(uint8_t, uint32_t) { return true; } - uint32_t busLastTransmitUs() const { return 0; } - void busDeinit() { cap_ = 0; buf_.clear(); ringActive_ = false; } - mm::platform::RmtLoopbackResult busLoopback(const uint8_t*, size_t, size_t, uint8_t) { return {}; } - - // The whole-frame reference: prefill constants + encode the entire frame in one call (what the ring - // must reproduce, slice by slice). - template - void encodeWholeForTest(uint8_t outCh, uint8_t* dst) { - this->template encodeRows(outCh, dst, 0, 0, /*closeFrame=*/true); - } - template - void prefillShiftFrameForTest(uint8_t outCh, uint8_t* dst) { - this->template prefillShiftFrame(outCh, dst); - } - // Which bus bit the '595's latch rides (the ragged darkness test masks it out of its bit check). - uint8_t latchBitForTest() const { return latchBit_; } - - // --- ring hooks (the seam the platform drives). The mock stores the trampoline + geometry and hands - // out plain-memory buffers; driveRingFrame() below replays the platform's prime+refill order. --- - bool wantsRing() const { return wantRing_; } + bool busInit(size_t frameBytes, bool) override { cap_ = frameBytes; buf_.assign(frameBytes, 0); return true; } + uint8_t* busBuffer(uint8_t i) override { return (i == 0 && !buf_.empty()) ? buf_.data() : nullptr; } + size_t busCapacity() const override { return cap_; } + bool busTransmit(uint8_t, size_t) override { return true; } + bool busWait(uint8_t, uint32_t) override { return true; } + uint32_t busLastTransmitUs() const override { return 0; } + void busDeinit() override { cap_ = 0; buf_.clear(); ringActive_ = false; } + mm::platform::RmtLoopbackResult busLoopback(const uint8_t*, size_t, size_t, uint8_t) override { return {}; } + + // --- ring hooks (the seam the platform drives). The mock stores the geometry and hands out + // plain-memory buffers; driveRingFrame() below replays the platform's prime+refill order. --- + bool wantsRing() const override { return wantRing_; } void setWantRing(bool w) { wantRing_ = w; } // Buffers are ROWS-ONLY, exactly as the platform allocates them: the WS2812 reset comes from stopping // the peripheral, never from a pad inside a circulating buffer. Sizing these rows+pad here would let a // pad-writing bug pass the tests and overrun on hardware. - bool busInitRing(size_t rowBytes, uint32_t totalRows) { + bool busInitRing(size_t rowBytes, uint32_t totalRows) override { ringRowBytes_ = rowBytes; ringTotalRows_ = totalRows; ringActive_ = true; @@ -108,8 +103,8 @@ class MockRingDriver : public mm::ParallelLedDriver { for (auto& f : needsPrefill_) f = true; return true; } - bool busIsRing() const { return ringActive_; } - bool busTransmitRing() { return true; } // the wire itself is the platform's; the encode is what we test + bool busIsRing() const override { return ringActive_; } + bool busTransmitRing() override { return true; } // the wire itself is the platform's; the encode is what we test // Replay one frame through the ring exactly as the platform does: prime the first min(N, needed) // buffers, then refill in ring order until the slice that reaches totalRows. Returns the frame @@ -151,8 +146,7 @@ class MockRingDriver : public mm::ParallelLedDriver { // and re-flag the buffer after a tail memset (its constants are gone for the NEXT use). const bool needsPrefill = needsPrefill_[slot]; needsPrefill_[slot] = false; - MockRingDriver::ringEncodeTrampolineHost(this, ring_[slot].data(), row, count, last, - needsPrefill); + ringEncodeTrampolineHost(ring_[slot].data(), row, count, last, needsPrefill); if (shortSlice) needsPrefill_[slot] = true; // Reassemble the row region in DMA order. assembled.insert(assembled.end(), ring_[slot].begin(), @@ -204,29 +198,20 @@ class MockRingDriver : public mm::ParallelLedDriver { return (ringTotalRows_ + kMockRingRows - 1) / kMockRingRows; } - // The trampoline the real driver registers is MoonLedDriver::ringEncodeTrampoline; the mock - // reproduces its body (recover `this`, branch on bus width, call encodeRows) so the host drives the - // identical encode the seam does on device. `closeFrame` is ALWAYS false to the encoder: the platform - // (encodeRingSlice) never appends a latch pad to a rows-only ring buffer β€” the WS2812 reset comes from - // stopping the peripheral, not from a pad inside a circulating buffer. `needsPrefill` mirrors the real - // trampoline's prefill-skip: constants are laid only when the platform says the buffer's are gone (or - // the lanes are ragged), and the byte-compare tests prove a data-only refill of a recycled buffer is - // identical to a full one. - static void ringEncodeTrampolineHost(void* user, uint8_t* dst, uint32_t firstRow, - uint32_t count, bool /*last*/, bool needsPrefill) { - auto* self = static_cast(user); - const uint8_t outCh = self->correction_.outChannels; - const auto first = static_cast(firstRow); - const auto cnt = static_cast(count); - const bool prefill = self->pinExpanderMode() && (needsPrefill || !self->uniformLaneCounts()); - if (self->slotBytes() == 1) { - if (prefill) self->template prefillShiftRows(outCh, dst, first, cnt); - self->template encodeRows(outCh, dst, first, cnt, /*closeFrame=*/false); - } else { - if (prefill) self->template prefillShiftRows(outCh, dst, first, cnt); - self->template encodeRows(outCh, dst, first, cnt, /*closeFrame=*/false); - } - } + // The trampoline the real driver registers is MoonI80Peripheral::ringEncodeTrampoline; the mock + // reproduces its body (recover the OWNER β€” this backend's attach()'d ParallelLedDriver β€” branch on + // bus width, call encodeRows) so the host drives the identical encode the seam does on device. + // `closeFrame` is ALWAYS false to the encoder: the platform (encodeRingSlice) never appends a latch + // pad to a rows-only ring buffer β€” the WS2812 reset comes from stopping the peripheral, not from a + // pad inside a circulating buffer. `needsPrefill` mirrors the real trampoline's prefill-skip: + // constants are laid only when the platform says the buffer's are gone (or the lanes are ragged), + // and the byte-compare tests prove a data-only refill of a recycled buffer is identical to a full one. + // + // Needs the owner's PROTECTED encodeRows/prefillShiftRows, which only a ParallelLedDriver subclass + // can reach β€” MockRingDriver (below) exposes them via a couple of one-line forwarders, mirroring how + // MoonI80Peripheral's real trampoline calls back through `owner_`. + void ringEncodeTrampolineHost(uint8_t* dst, uint32_t firstRow, uint32_t count, bool /*last*/, + bool needsPrefill); size_t rowBytesForTest() const { return ringRowBytes_; } @@ -276,7 +261,7 @@ class MockRingDriver : public mm::ParallelLedDriver { const bool last = (firstRow + count >= ringTotalRows_); const bool needsPrefill = poolNeedsPrefill[slot]; poolNeedsPrefill[slot] = false; - ringEncodeTrampolineHost(this, pool[slot].data(), firstRow, count, last, needsPrefill); + ringEncodeTrampolineHost(pool[slot].data(), firstRow, count, last, needsPrefill); if (shortSlice) poolNeedsPrefill[slot] = true; // the tail memset erased those rows' constants }; uint32_t refilledRow = 0; @@ -326,6 +311,57 @@ class MockRingDriver : public mm::ParallelLedDriver { return out; } +private: + std::vector buf_; + size_t cap_ = 0; + std::vector ring_[kMockRingBufs]; + bool needsPrefill_[kMockRingBufs] = {}; // the platform's bufNeedsPrefill lifecycle, mirrored + size_t ringRowBytes_ = 0; + uint32_t ringTotalRows_ = 0; + bool ringActive_ = false; + bool wantRing_ = false; + int32_t lastSlot_ = -1; +}; + +// A tiny ParallelLedDriver subclass that exposes the protected lane/encode/snapshot internals the ring +// tests need (encodeRows, prefillShiftRows/Frame, latchBit_'s bit position, the ensureSnapshotCap / +// snapshotSourceForRing / copyRange snapshot machinery) without widening the production class's public +// surface β€” same pattern as unit_ParallelLedDriver_pinexpander.cpp's Expose-style mocks. Everything else +// the tests use (laneCount, maxLaneLights, frameBytes, pins, ledsPerPin, pinExpander, latchPin, +// defineControls, setSourceBuffer, correctionForTest, applyState, severity, outputsPerPin, +// setWindow, tick) is already public on ParallelLedDriver itself. +class MockRingDriver : public mm::ParallelLedDriver { +public: + // The whole-frame reference: prefill constants + encode the entire frame in one call (what the ring + // must reproduce, slice by slice). + template + void encodeWholeForTest(uint8_t outCh, uint8_t* dst) { + this->template encodeRows(outCh, dst, 0, 0, /*closeFrame=*/true); + } + template + void prefillShiftFrameForTest(uint8_t outCh, uint8_t* dst) { + this->template prefillShiftFrame(outCh, dst); + } + // Which bus bit the '595's latch rides (the ragged darkness test masks it out of its bit check). + uint8_t latchBitForTest() const { return this->latchBit(); } + + // The trampoline body itself (recover outCh/prefill-gate from this driver, branch on bus width, call + // encodeRows) β€” MockRingPeripheral::ringEncodeTrampolineHost forwards here, mirroring how + // MoonI80Peripheral's real trampoline recovers `owner_` and calls straight into it. + void encodeSliceForTest(uint8_t* dst, uint32_t firstRow, uint32_t count, bool needsPrefill) { + const uint8_t outCh = this->correction().outChannels; + const auto first = static_cast(firstRow); + const auto cnt = static_cast(count); + const bool prefill = this->pinExpanderMode() && (needsPrefill || !this->uniformLaneCounts()); + if (this->slotBytes() == 1) { + if (prefill) this->template prefillShiftRows(outCh, dst, first, cnt); + this->template encodeRows(outCh, dst, first, cnt, /*closeFrame=*/false); + } else { + if (prefill) this->template prefillShiftRows(outCh, dst, first, cnt); + this->template encodeRows(outCh, dst, first, cnt, /*closeFrame=*/false); + } + } + // Freeze the current source into the driver-owned snapshot and route the ring encode at it β€” the same // call tickRing makes before kicking a frame. After this, encodeRows reads the snapshot, so mutating // the live source (a resize / repaint on the render thread) can't tear or UAF the in-flight frame. @@ -350,19 +386,15 @@ class MockRingDriver : public mm::ParallelLedDriver { static mm::nrOfLightsType snapHalfForTest(mm::nrOfLightsType n, size_t chStride) { return snapLineAlignedHalf(n, chStride); } - -private: - std::vector buf_; - size_t cap_ = 0; - std::vector ring_[kMockRingBufs]; - bool needsPrefill_[kMockRingBufs] = {}; // the platform's bufNeedsPrefill lifecycle, mirrored - size_t ringRowBytes_ = 0; - uint32_t ringTotalRows_ = 0; - bool ringActive_ = false; - bool wantRing_ = false; - int32_t lastSlot_ = -1; }; +// Out-of-line: needs MockRingDriver's full definition (encodeSliceForTest), so it's defined after the +// class rather than inline in MockRingPeripheral. +inline void MockRingPeripheral::ringEncodeTrampolineHost(uint8_t* dst, uint32_t firstRow, uint32_t count, + bool /*last*/, bool needsPrefill) { + static_cast(owner_)->encodeSliceForTest(dst, firstRow, count, needsPrefill); +} + // Bring the mock up on `lights` lights, shift mode (8 pins Γ— 8 = 64 strands... capped; use fewer pins), // with a correction so outChannels is known. Mirrors the shiftregister test's setup. // @@ -370,8 +402,9 @@ class MockRingDriver : public mm::ParallelLedDriver { // "" leaves every strand equal at `lights`. Pass an explicit list for a RAGGED frame, where strands have // different lengths (an end user's mix of strips and panels), which is what makes the active mask change // mid-frame rather than being one constant. -void wireShift(MockRingDriver& d, mm::Buffer& src, mm::Correction& corr, nrOfLightsType lights, - const char* pins, const char* ledsPerPin = "") { +void wireShift(MockRingDriver& d, MockRingPeripheral& peripheral, mm::Buffer& src, mm::Correction& corr, + nrOfLightsType lights, const char* pins, const char* ledsPerPin = "") { + d.setPeripheralForTest(&peripheral); std::strcpy(d.pins, pins); std::strcpy(d.ledsPerPin, ledsPerPin); d.pinExpander = true; @@ -395,10 +428,11 @@ void wireShift(MockRingDriver& d, mm::Buffer& src, mm::Correction& corr, nrOfLig // writes to dst+0 for any firstRow, so if the tiling is right the reassembled buffer == the frame. TEST_CASE("MoonI80 ring: sliced encode tiles into a byte-identical whole frame") { MockRingDriver d; + MockRingPeripheral peripheral; mm::Buffer src; mm::Correction corr; // 200 lights/strand > 16 rows/buffer Γ— 4 buffers = 64, so this needs real refills (not fits-in-ring). - wireShift(d, src, corr, 200, "1,2"); // 2 pins Γ— 8 = 16 strands, 200 lights each + wireShift(d, peripheral, src, corr, 200, "1,2"); // 2 pins Γ— 8 = 16 strands, 200 lights each // Paint the source so every row differs (a tiling bug that repeats a slice would then be visible). uint8_t* s = src.data(); for (nrOfLightsType i = 0; i < src.count(); i++) { @@ -413,16 +447,16 @@ TEST_CASE("MoonI80 ring: sliced encode tiles into a byte-identical whole frame") // Reference: one whole-frame encode (2 pins β†’ 8-bit bus β†’ uint8 slots). encodeRows in shift mode // writes only data words, so prefill the whole buffer's constants first β€” same as reinit does. - d.busInit(d.frameBytes(), false); - d.prefillShiftFrameForTest(outCh, d.busBuffer(0)); - d.encodeWholeForTest(outCh, d.busBuffer(0)); - std::vector whole(d.busBuffer(0), d.busBuffer(0) + rowRegion); - d.busDeinit(); + peripheral.busInit(d.frameBytes(), false); + d.prefillShiftFrameForTest(outCh, peripheral.busBuffer(0)); + d.encodeWholeForTest(outCh, peripheral.busBuffer(0)); + std::vector whole(peripheral.busBuffer(0), peripheral.busBuffer(0) + rowRegion); + peripheral.busDeinit(); // Ring: drive the frame slice by slice, reassemble the row region. - d.setWantRing(true); - REQUIRE(d.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); - std::vector assembled = d.driveRingFrame(); + peripheral.setWantRing(true); + REQUIRE(peripheral.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); + std::vector assembled = peripheral.driveRingFrame(); REQUIRE(assembled.size() == whole.size()); CHECK(std::memcmp(assembled.data(), whole.data(), whole.size()) == 0); @@ -433,16 +467,17 @@ TEST_CASE("MoonI80 ring: sliced encode tiles into a byte-identical whole frame") // buffer), so a slice that wrote a latch word past its rows would overrun the allocation on hardware. TEST_CASE("MoonI80 ring: no slice writes past its rows (buffers are rows-only)") { MockRingDriver d; + MockRingPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wireShift(d, src, corr, 200, "1,2"); + wireShift(d, peripheral, src, corr, 200, "1,2"); const uint8_t outCh = corr.outChannels; - d.setWantRing(true); + peripheral.setWantRing(true); const size_t rowBytes = static_cast(outCh) * 24 * 1 * d.outputsPerPin(); - REQUIRE(d.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); - d.driveRingFrame(); - CHECK(d.noSliceWritesPad()); + REQUIRE(peripheral.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); + peripheral.driveRingFrame(); + CHECK(peripheral.noSliceWritesPad()); } // 3. RECYCLED == FRESH β€” a second frame through the SAME (recycled, not zeroed) ring buffers produces @@ -450,9 +485,10 @@ TEST_CASE("MoonI80 ring: no slice writes past its rows (buffers are rows-only)") // single-frame test cannot see β€” the failure mode unique to a recycled ring. TEST_CASE("MoonI80 ring: a recycled buffer produces the same bytes as a fresh one") { MockRingDriver d; + MockRingPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wireShift(d, src, corr, 200, "1,2"); + wireShift(d, peripheral, src, corr, 200, "1,2"); uint8_t* s = src.data(); for (nrOfLightsType i = 0; i < src.count(); i++) { s[i * 3 + 0] = static_cast(i * 5 + 3); @@ -461,12 +497,12 @@ TEST_CASE("MoonI80 ring: a recycled buffer produces the same bytes as a fresh on } const uint8_t outCh = corr.outChannels; - d.setWantRing(true); + peripheral.setWantRing(true); const size_t rowBytes = static_cast(outCh) * 24 * 1 * d.outputsPerPin(); - REQUIRE(d.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); + REQUIRE(peripheral.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); - std::vector first = d.driveRingFrame(); - std::vector second = d.driveRingFrame(); // same buffers, recycled β€” not re-init'd + std::vector first = peripheral.driveRingFrame(); + std::vector second = peripheral.driveRingFrame(); // same buffers, recycled β€” not re-init'd REQUIRE(first.size() == second.size()); CHECK(std::memcmp(first.data(), second.data(), first.size()) == 0); } @@ -477,9 +513,10 @@ TEST_CASE("MoonI80 ring: a recycled buffer produces the same bytes as a fresh on // 8 buffers (reuse) AND lastRows=8 (short) β€” exactly the case 128/192/256 (all Γ—16) never hit. TEST_CASE("MoonI80 ring: a short last slice in a reused buffer has a clean pad (no stale ghost rows)") { MockRingDriver d; + MockRingPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wireShift(d, src, corr, 200, "1,2"); // 200 lights/strand: 13 slices, last slice = 8 rows + wireShift(d, peripheral, src, corr, 200, "1,2"); // 200 lights/strand: 13 slices, last slice = 8 rows uint8_t* s = src.data(); for (nrOfLightsType i = 0; i < src.count(); i++) { // dense non-zero source so a stale row WOULD show s[i * 3 + 0] = static_cast(i * 9 + 1); @@ -487,14 +524,14 @@ TEST_CASE("MoonI80 ring: a short last slice in a reused buffer has a clean pad ( s[i * 3 + 2] = static_cast(i * 19 + 2); } const uint8_t outCh = corr.outChannels; - d.setWantRing(true); + peripheral.setWantRing(true); const size_t rowBytes = static_cast(outCh) * 24 * 1 * d.outputsPerPin(); - REQUIRE(d.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); + REQUIRE(peripheral.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); // Drive TWICE so the last-slice buffer is genuinely recycled (held an earlier frame's full slice). - d.driveRingFrame(); - d.driveRingFrame(); - CHECK(d.lastSliceStalePadBytes() == 0); // tail past the short slice's rows is zero β€” no ghost rows + peripheral.driveRingFrame(); + peripheral.driveRingFrame(); + CHECK(peripheral.lastSliceStalePadBytes() == 0); // tail past the short slice's rows is zero β€” no ghost rows } // 5. SOURCE SNAPSHOT β€” the ring encodes off the render thread across the ~6 ms wire, so it must read a @@ -505,9 +542,10 @@ TEST_CASE("MoonI80 ring: a short last slice in a reused buffer has a clean pad ( // mid-wire from tearing or reading freed memory.) TEST_CASE("MoonI80 ring: the encode reads a per-frame snapshot, not the live source") { MockRingDriver d; + MockRingPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wireShift(d, src, corr, 200, "1,2"); + wireShift(d, peripheral, src, corr, 200, "1,2"); uint8_t* s = src.data(); for (nrOfLightsType i = 0; i < src.count(); i++) { s[i * 3 + 0] = static_cast(i * 5 + 3); @@ -515,16 +553,16 @@ TEST_CASE("MoonI80 ring: the encode reads a per-frame snapshot, not the live sou s[i * 3 + 2] = static_cast(i * 17 + 7); } const uint8_t outCh = corr.outChannels; - d.setWantRing(true); + peripheral.setWantRing(true); const size_t rowBytes = static_cast(outCh) * 24 * 1 * d.outputsPerPin(); - REQUIRE(d.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); + REQUIRE(peripheral.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); // Freeze the source, then SCRIBBLE all over the live buffer as the render thread would between kick // and wire-completion. The snapshot must shield the encode from it. REQUIRE(d.snapshotForTest()); - std::vector fromSnapshot = d.driveRingFrame(); + std::vector fromSnapshot = peripheral.driveRingFrame(); std::memset(src.data(), 0xA5, static_cast(src.count()) * src.channelsPerLight()); - std::vector afterMutation = d.driveRingFrame(); // still on the same snapshot + std::vector afterMutation = peripheral.driveRingFrame(); // still on the same snapshot REQUIRE(fromSnapshot.size() == afterMutation.size()); CHECK(std::memcmp(fromSnapshot.data(), afterMutation.data(), fromSnapshot.size()) == 0); @@ -533,7 +571,7 @@ TEST_CASE("MoonI80 ring: the encode reads a per-frame snapshot, not the live sou // scribbled buffer and confirm the encode follows it (a uniform 0xA5 source β†’ uniform encoded bytes, // clearly different from the structured pattern above). REQUIRE(d.snapshotForTest()); - std::vector fromMutated = d.driveRingFrame(); + std::vector fromMutated = peripheral.driveRingFrame(); REQUIRE(fromMutated.size() == fromSnapshot.size()); CHECK(std::memcmp(fromMutated.data(), fromSnapshot.data(), fromMutated.size()) != 0); } @@ -546,9 +584,10 @@ TEST_CASE("MoonI80 ring: the encode reads a per-frame snapshot, not the live sou TEST_CASE("MoonI80 ring: the windowed snapshot bias reads this driver's slice, not from light 0") { // Reference: a driver whose window starts at 0 over a buffer sized for exactly its strands. MockRingDriver ref; + MockRingPeripheral peripheralRef; mm::Buffer refSrc; mm::Correction corr; - wireShift(ref, refSrc, corr, 64, "1,2"); // 16 strands Γ— 64 lights = 1024-light window + wireShift(ref, peripheralRef, refSrc, corr, 64, "1,2"); // 16 strands Γ— 64 lights = 1024-light window const nrOfLightsType winLights = refSrc.count(); // the whole buffer IS the window here auto paint = [](uint8_t* p, nrOfLightsType n, nrOfLightsType base) { for (nrOfLightsType i = 0; i < n; i++) { @@ -558,27 +597,28 @@ TEST_CASE("MoonI80 ring: the windowed snapshot bias reads this driver's slice, n } }; paint(refSrc.data(), winLights, /*base=*/64); // same pixel VALUES the windowed driver will see - ref.setWantRing(true); + peripheralRef.setWantRing(true); const size_t rowBytes = static_cast(corr.outChannels) * 24 * 1 * ref.outputsPerPin(); - REQUIRE(ref.busInitRing(rowBytes, static_cast(ref.maxLaneLights()))); + REQUIRE(peripheralRef.busInitRing(rowBytes, static_cast(ref.maxLaneLights()))); REQUIRE(ref.snapshotForTest()); - std::vector refFrame = ref.driveRingFrame(); + std::vector refFrame = peripheralRef.driveRingFrame(); // Windowed: a bigger buffer, the driver's window offset to start=64, painted so window pixel k equals // reference pixel k. The bias must make the snapshot read [64, 64+winLights), i.e. the SAME values. MockRingDriver win; + MockRingPeripheral peripheralWin; mm::Buffer winSrc; mm::Correction corr2; - wireShift(win, winSrc, corr2, 64, "1,2"); // same geometry... + wireShift(win, peripheralWin, winSrc, corr2, 64, "1,2"); // same geometry... // ...but re-allocate the source with a 64-light lead-in the window skips, and re-apply the window. REQUIRE(winSrc.allocate(winLights + 64, 3) == true); paint(winSrc.data(), winLights + 64, /*base=*/0); // pixel 64.. == refSrc pixel 0.. (base 64) win.setWindow(64, winLights); win.applyState(); - win.setWantRing(true); - REQUIRE(win.busInitRing(rowBytes, static_cast(win.maxLaneLights()))); + peripheralWin.setWantRing(true); + REQUIRE(peripheralWin.busInitRing(rowBytes, static_cast(win.maxLaneLights()))); REQUIRE(win.snapshotForTest()); - std::vector winFrame = win.driveRingFrame(); + std::vector winFrame = peripheralWin.driveRingFrame(); REQUIRE(refFrame.size() == winFrame.size()); CHECK(std::memcmp(refFrame.data(), winFrame.data(), refFrame.size()) == 0); @@ -596,20 +636,21 @@ TEST_CASE("MoonI80 ring: the windowed snapshot bias reads this driver's slice, n // is the no-reuse stopgap's guarantee; it is what renders clean at ≀240 lights/strand (kRingBufs=16). TEST_CASE("MoonI80 ring: no-reuse frame clocks a clean LOW tail and stops deterministically") { MockRingDriver d; + MockRingPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wireShift(d, src, corr, 176, "1,2"); // 176 lights = 11 slices; model 16 buffers β†’ NO reuse + wireShift(d, peripheral, src, corr, 176, "1,2"); // 176 lights = 11 slices; model 16 buffers β†’ NO reuse uint8_t* s = src.data(); for (nrOfLightsType i = 0; i < src.count(); i++) { // dense non-zero so a dirty tail WOULD show s[i * 3 + 0] = static_cast(i * 7 + 1); s[i * 3 + 1] = static_cast(i * 13 + 5); s[i * 3 + 2] = static_cast(i * 29 + 2); } - d.setWantRing(true); + peripheral.setWantRing(true); const size_t rowBytes = static_cast(corr.outChannels) * 24 * 1 * d.outputsPerPin(); - REQUIRE(d.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); + REQUIRE(peripheral.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); - auto f = d.driveRingFrameWithTermination(/*bufs=*/16, /*kTailBufs=*/1); + auto f = peripheral.driveRingFrameWithTermination(/*bufs=*/16, /*kTailBufs=*/1); const uint32_t nSlices = (176 + 15) / 16; // 11 CHECK(f.drainsToStop == nSlices + 1); // stops one buffer LATE (the tail), not early, not looping forever CHECK(f.tailIsLow); // the buffer(s) past the last real slice clock all-LOW @@ -631,27 +672,28 @@ TEST_CASE("MoonI80 ring: no-reuse frame clocks a clean LOW tail and stops determ // stop timing at the correct sizing is caught here; the equal-case stall is covered in the backlog. TEST_CASE("MoonI80 ring: the no-reuse stopgap clocks a clean tail and stops on the drain counter") { MockRingDriver d; + MockRingPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wireShift(d, src, corr, 176, "1,2"); // 11 slices + wireShift(d, peripheral, src, corr, 176, "1,2"); // 11 slices uint8_t* s = src.data(); for (nrOfLightsType i = 0; i < src.count(); i++) { s[i * 3 + 0] = static_cast(i * 7 + 1); s[i * 3 + 1] = static_cast(i * 13 + 5); s[i * 3 + 2] = static_cast(i * 29 + 2); } - d.setWantRing(true); + peripheral.setWantRing(true); const size_t rowBytes = static_cast(corr.outChannels) * 24 * 1 * d.outputsPerPin(); - REQUIRE(d.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); + REQUIRE(peripheral.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); const uint32_t nSlices = (176 + 15) / 16; // 11 // bufs = nSlices + 1 (the correct stopgap sizing, kRingBufs > nSlices): clean LOW tail, stop on the // drain counter one buffer past the last real slice. This is the ≀240-lights/strand no-reuse guarantee. - auto ok = d.driveRingFrameWithTermination(/*bufs=*/static_cast(nSlices + 1), /*kTailBufs=*/1); + auto ok = peripheral.driveRingFrameWithTermination(/*bufs=*/static_cast(nSlices + 1), /*kTailBufs=*/1); CHECK(ok.tailIsLow); CHECK(ok.drainsToStop == nSlices + 1); // A deeper pool (kRingBufs=16 for 11 slices, the shipped stopgap) is equally clean and stops the same. - auto deep = d.driveRingFrameWithTermination(/*bufs=*/16, /*kTailBufs=*/1); + auto deep = peripheral.driveRingFrameWithTermination(/*bufs=*/16, /*kTailBufs=*/1); CHECK(deep.tailIsLow); CHECK(deep.drainsToStop == nSlices + 1); } @@ -673,12 +715,13 @@ TEST_CASE("MoonI80 ring: the no-reuse stopgap clocks a clean tail and stops on t // slice) nor the driver-level ring tests (which are never ragged) reach on their own. TEST_CASE("MoonI80 ring: a ragged frame tiles byte-identically (a strand ending mid-slice)") { MockRingDriver d; + MockRingPeripheral peripheral; mm::Buffer src; mm::Correction corr; // 16 strands (2 pins Γ— 8). Strand 0 runs the full 200; strands 3 and 9 end at 100 and 57 β€” both // INSIDE a 16-row slice (100 = slice 6 row 4; 57 = slice 3 row 9), and 57 is not a multiple of // anything convenient, which is the point. - wireShift(d, src, corr, 200, "1,2", + wireShift(d, peripheral, src, corr, 200, "1,2", "200,200,200,100,200,200,200,200,200,57,200,200,200,200,200,200"); uint8_t* s = src.data(); for (nrOfLightsType i = 0; i < src.count(); i++) { @@ -693,15 +736,15 @@ TEST_CASE("MoonI80 ring: a ragged frame tiles byte-identically (a strand ending // The frame is still as long as the LONGEST strand β€” the short ones just go dark early. REQUIRE(d.maxLaneLights() == 200); - d.busInit(d.frameBytes(), false); - d.prefillShiftFrameForTest(outCh, d.busBuffer(0)); - d.encodeWholeForTest(outCh, d.busBuffer(0)); - std::vector whole(d.busBuffer(0), d.busBuffer(0) + rowRegion); - d.busDeinit(); + peripheral.busInit(d.frameBytes(), false); + d.prefillShiftFrameForTest(outCh, peripheral.busBuffer(0)); + d.encodeWholeForTest(outCh, peripheral.busBuffer(0)); + std::vector whole(peripheral.busBuffer(0), peripheral.busBuffer(0) + rowRegion); + peripheral.busDeinit(); - d.setWantRing(true); - REQUIRE(d.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); - std::vector assembled = d.driveRingFrame(); + peripheral.setWantRing(true); + REQUIRE(peripheral.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); + std::vector assembled = peripheral.driveRingFrame(); REQUIRE(assembled.size() == whole.size()); CHECK(std::memcmp(assembled.data(), whole.data(), whole.size()) == 0); @@ -715,9 +758,10 @@ TEST_CASE("MoonI80 ring: a ragged frame tiles byte-identically (a strand ending // cross the recycle boundary repeatedly. TEST_CASE("MoonI80 ring v2: coalesced EOFs (batched refill) are byte-identical to one-per-EOF") { MockRingDriver d; + MockRingPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wireShift(d, src, corr, 200, "1,2", ""); // 16 uniform strands, 200 rows: 13 slices over the mock pool + wireShift(d, peripheral, src, corr, 200, "1,2", ""); // 16 uniform strands, 200 rows: 13 slices over the mock pool uint8_t* s = src.data(); for (nrOfLightsType i = 0; i < src.count(); i++) { s[i * 3 + 0] = static_cast(i * 5 + 3); @@ -727,11 +771,11 @@ TEST_CASE("MoonI80 ring v2: coalesced EOFs (batched refill) are byte-identical t const uint8_t outCh = corr.outChannels; const size_t rowBytes = static_cast(outCh) * 24 * 1 * d.outputsPerPin(); - d.setWantRing(true); - REQUIRE(d.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); - std::vector onePerEof = d.driveRingFrame(); // the reference grouping - std::vector coalesced2 = d.driveRingFrameCoalesced(2); // every firing carries 2 drains - std::vector coalesced5 = d.driveRingFrameCoalesced(5); // deep coalescing (a long stall) + peripheral.setWantRing(true); + REQUIRE(peripheral.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); + std::vector onePerEof = peripheral.driveRingFrame(); // the reference grouping + std::vector coalesced2 = peripheral.driveRingFrameCoalesced(2); // every firing carries 2 drains + std::vector coalesced5 = peripheral.driveRingFrameCoalesced(5); // deep coalescing (a long stall) REQUIRE(coalesced2.size() == onePerEof.size()); REQUIRE(coalesced5.size() == onePerEof.size()); @@ -746,10 +790,11 @@ TEST_CASE("MoonI80 ring v2: coalesced EOFs (batched refill) are byte-identical t // frames β€” which now skip the prefill β€” stay byte-identical to the whole-frame encode. TEST_CASE("MoonI80 ring: an EMPTY lane does not break uniformity (prefill-skip stays valid)") { MockRingDriver d; + MockRingPeripheral peripheral; mm::Buffer src; mm::Correction corr; // 15 strands at the full 200, the 16th empty β€” the 3840-lights-on-16-strands wall shape. - wireShift(d, src, corr, 200, "1,2", + wireShift(d, peripheral, src, corr, 200, "1,2", "200,200,200,200,200,200,200,200,200,200,200,200,200,200,200,0"); CHECK(d.uniformLaneCounts()); // the gate itself: empty lane ignored uint8_t* s = src.data(); @@ -762,15 +807,15 @@ TEST_CASE("MoonI80 ring: an EMPTY lane does not break uniformity (prefill-skip s const size_t rowBytes = static_cast(outCh) * 24 * 1 * d.outputsPerPin(); const size_t rowRegion = static_cast(d.maxLaneLights()) * rowBytes; - d.busInit(d.frameBytes(), false); - d.prefillShiftFrameForTest(outCh, d.busBuffer(0)); - d.encodeWholeForTest(outCh, d.busBuffer(0)); - std::vector whole(d.busBuffer(0), d.busBuffer(0) + rowRegion); - d.busDeinit(); + peripheral.busInit(d.frameBytes(), false); + d.prefillShiftFrameForTest(outCh, peripheral.busBuffer(0)); + d.encodeWholeForTest(outCh, peripheral.busBuffer(0)); + std::vector whole(peripheral.busBuffer(0), peripheral.busBuffer(0) + rowRegion); + peripheral.busDeinit(); - d.setWantRing(true); - REQUIRE(d.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); - std::vector assembled = d.driveRingFrame(); + peripheral.setWantRing(true); + REQUIRE(peripheral.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); + std::vector assembled = peripheral.driveRingFrame(); REQUIRE(assembled.size() == whole.size()); CHECK(std::memcmp(assembled.data(), whole.data(), whole.size()) == 0); @@ -790,18 +835,19 @@ TEST_CASE("MoonI80 ring: an EMPTY lane does not break uniformity (prefill-skip s // buffers are reused, not zeroed), which is where a "lay it once at init" shortcut breaks on frame 2. TEST_CASE("MoonI80 ring: an exhausted RAGGED strand clocks zeros, on a fresh AND a recycled buffer") { MockRingDriver d; + MockRingPeripheral peripheral; mm::Buffer src; mm::Correction corr; // Strand 0 alone runs the full 200; every other strand on both '595s ends at 8 β€” so from row 8 the // mask is a single bit, and 15 of 16 strands must be silent for the remaining 192 rows. - wireShift(d, src, corr, 200, "1,2", "200,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8"); + wireShift(d, peripheral, src, corr, 200, "1,2", "200,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8"); std::memset(src.data(), 0xFF, static_cast(src.count()) * 3); // a leak shows as full white const uint8_t outCh = corr.outChannels; const size_t rowBytes = static_cast(outCh) * 24 * 1 * d.outputsPerPin(); REQUIRE(d.maxLaneLights() == 200); - d.setWantRing(true); - REQUIRE(d.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); + peripheral.setWantRing(true); + REQUIRE(peripheral.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); // Strand 0 is pin 0's shift position 0; the '595 shifts MSB-first, so that strand rides bit 0 of the // bus word at cycle outputsPerPin-1. Every OTHER bus bit must be 0 for rows >= 8: bit p is pin p's @@ -820,9 +866,9 @@ TEST_CASE("MoonI80 ring: an exhausted RAGGED strand clocks zeros, on a fresh AND INFO("exhausted strands leaked on " << which << ": " << leaked << " bytes"); CHECK(leaked == 0); }; - std::vector first = d.driveRingFrame(); + std::vector first = peripheral.driveRingFrame(); checkDark(first, "the first frame"); - std::vector second = d.driveRingFrame(); // same buffers, recycled + std::vector second = peripheral.driveRingFrame(); // same buffers, recycled checkDark(second, "a recycled buffer"); // And the two laps agree byte for byte β€” a recycled buffer is not a fresh one only by accident. REQUIRE(first.size() == second.size()); @@ -846,22 +892,23 @@ TEST_CASE("MoonI80 ring: an exhausted RAGGED strand clocks zeros, on a fresh AND // UNCONDITIONALLY. The mock's busDeinit clears ringActive_, so a surviving ring is visible here. TEST_CASE("MoonI80 ring: a failed ring build tears the bus down (no leaked ring)") { MockRingDriver d; + MockRingPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wireShift(d, src, corr, 200, "1,2"); + wireShift(d, peripheral, src, corr, 200, "1,2"); const uint8_t outCh = corr.outChannels; const size_t rowBytes = static_cast(outCh) * 24 * 1 * d.outputsPerPin(); // A ring that built successfully β€” the exact state the failure path must not leave behind. - d.setWantRing(true); - REQUIRE(d.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); - REQUIRE(d.busIsRing()); + peripheral.setWantRing(true); + REQUIRE(peripheral.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); + REQUIRE(peripheral.busIsRing()); // The fall-through's teardown, as reinit() performs it. It must not be gated on `inited_` β€” which is // false here, exactly as it is in production on this path. - d.busDeinit(); - CHECK_FALSE(d.busIsRing()); // the ring is gone, not merely unreferenced + peripheral.busDeinit(); + CHECK_FALSE(peripheral.busIsRing()); // the ring is gone, not merely unreferenced } TEST_CASE("MoonI80 ring: the PARALLEL snapshot's range-split is byte-identical to the whole-range serial") { @@ -870,9 +917,10 @@ TEST_CASE("MoonI80 ring: the PARALLEL snapshot's range-split is byte-identical t // ranges are disjoint and stateless: copyRange(0,half)+copyRange(half,N) == copyRange(0,N). The // snapshot is now a raw memcpy at SOURCE channel stride (correction fuses into encodeRows downstream). MockRingDriver d; + MockRingPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wireShift(d, src, corr, 200, "1,2"); // 16 strands Γ— 200, a real window + wireShift(d, peripheral, src, corr, 200, "1,2"); // 16 strands Γ— 200, a real window // Distinctive per-light source so a mis-split (gap/overlap/wrong stride) can't accidentally match. uint8_t* s = src.data(); for (mm::nrOfLightsType i = 0; i < src.count(); i++) { @@ -883,8 +931,8 @@ TEST_CASE("MoonI80 ring: the PARALLEL snapshot's range-split is byte-identical t const uint8_t outCh = corr.outChannels; const uint8_t srcCh = static_cast(src.channelsPerLight()); const size_t rowBytes = static_cast(outCh) * 24 * 1 * d.outputsPerPin(); - d.setWantRing(true); - REQUIRE(d.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); + peripheral.setWantRing(true); + REQUIRE(peripheral.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); // snapshotForTest sizes snapshotBuf_ (ensureSnapshotCap) and runs one full serial snapshot β€” after it, // the snapshot state (snapCopy*) is set and the buffer exists, so the manual re-runs below are safe. REQUIRE(d.snapshotForTest()); @@ -918,9 +966,10 @@ TEST_CASE("MoonI80 ring: the PARALLEL snapshot's range-split is byte-identical t // its tail reading stale bytes. This pins the full window survives. TEST_CASE("MoonI80 ring: snapshot keeps the whole window when outCh > srcCh (RGBW correction on RGB source)") { MockRingDriver d; + MockRingPeripheral peripheral; mm::Buffer src; mm::Correction corr; - wireShift(d, src, corr, 200, "1,2"); // 16 strands Γ— 200, RGB source (srcCh=3) + wireShift(d, peripheral, src, corr, 200, "1,2"); // 16 strands Γ— 200, RGB source (srcCh=3) // Swap in an RGBW correction (outCh=4) so outCh > the source's 3 channels β€” the failing condition. mm::test::rebuildFromPreset(corr, 255, mm::test::PresetOrder::RGBW); d.correctionForTest() = corr; @@ -928,11 +977,11 @@ TEST_CASE("MoonI80 ring: snapshot keeps the whole window when outCh > srcCh (RGB REQUIRE(corr.outChannels == 4); REQUIRE(src.channelsPerLight() == 3); - d.setWantRing(true); + peripheral.setWantRing(true); const uint8_t outCh = corr.outChannels; const uint8_t srcCh = static_cast(src.channelsPerLight()); const size_t rowBytes = static_cast(outCh) * 24 * 1 * d.outputsPerPin(); - REQUIRE(d.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); + REQUIRE(peripheral.busInitRing(rowBytes, static_cast(d.maxLaneLights()))); // Paint the source window's LAST light a distinct value; the snapshot must copy it. With the buggy // outCh clamp the window shrinks to winLen*3/4, so the last quarter (including this light) is never @@ -969,10 +1018,14 @@ TEST_CASE("MoonI80 ring: ringSnapshot control is hidden unless the ring is activ return false; }; MockRingDriver ringing; - ringing.setWantRing(true); + MockRingPeripheral peripheralRinging; + ringing.setPeripheralForTest(&peripheralRinging); + peripheralRinging.setWantRing(true); CHECK_FALSE(ringSnapshotHidden(ringing)); // ring active β†’ visible MockRingDriver whole; - whole.setWantRing(false); + MockRingPeripheral peripheralWhole; + whole.setPeripheralForTest(&peripheralWhole); + peripheralWhole.setWantRing(false); CHECK(ringSnapshotHidden(whole)); // no ring β†’ hidden } diff --git a/test/unit/light/unit_ParallelLedDriver_swap.cpp b/test/unit/light/unit_ParallelLedDriver_swap.cpp new file mode 100644 index 00000000..4875ea7c --- /dev/null +++ b/test/unit/light/unit_ParallelLedDriver_swap.cpp @@ -0,0 +1,259 @@ +// @module ParallelLedDriver +// @also MultiPinLedDriver, ParlioLedDriver + +#include "doctest.h" +#include "light/drivers/ParallelLedDriver.h" +#include "light/drivers/Correction.h" +#include "correction_presets.h" +#include "light/layers/Buffer.h" +#include "unit/core/conditional_controls.h" // controlIndex + +#include +#include + +// Host test of the runtime PERIPHERAL SWAP path β€” the `peripheral` Select changing on a live driver. +// The borrowed-mock tests (setPeripheralForTest) deliberately do NOT exercise this: a borrowed backend +// is !peripheralOwned_, so ensurePeripheralMatchesSelection() short-circuits and the registry swap +// never runs. To pin the swap we REGISTER two owned mock backends and drive the exact sequence +// Scheduler::setControl runs on a control change: +// +// applyControlValue writes peripheralSel_ β†’ rebuildControls() β†’ onControlChanged("peripheral") +// +// The bug this pins (found in the parallel-driver consolidation review): rebuildControls() already +// swaps the backend (ensurePeripheralMatchesSelection) and binds its members into the control list, +// so onControlChanged must NOT swap AGAIN β€” a second swap frees the just-bound backend and leaves +// every backend-owned control dangling into freed memory. Desktop's own registry is empty, so only a +// test that populates it can catch this. + +namespace { + +// The quiesce-render hook's fire count, and whether it had fired BEFORE the last attached backend was +// freed. The swap-ordering test installs a hook that bumps g_hookFires; SwapMock's destructor snapshots +// (into g_hookFiredBeforeLastDtor) whether the hook had already fired by the time an ATTACHED backend is +// destroyed β€” the proof that the worker was quiesced before the backend it reads was freed, not merely +// that the hook fired at some point during the swap. +int g_hookFires = 0; +bool g_hookFiredBeforeLastDtor = false; + +// A minimal owned backend (created by the registry, deleted by the driver). Reports a real hwBlock so +// the two labels model two distinct peripherals, and counts its own construction/destruction so the +// test can assert no leak and no double-free across a swap. +struct SwapMock : mm::LedPeripheral { + static inline int live = 0; // net live instances (ctor++ / dtor--) β€” 0 at rest, no leak/double-free + static inline int attachedDtors = 0; // destructions of a backend that was ATTACHED to a driver (i.e. + // a real selected backend, not a buildPeripheralOptions() probe β€” probes + // are make()/delete'd without attach()). A control change swaps to ONE + // backend, so across the whole swap sequence the only attached backend + // destroyed is the one being replaced. The double-swap bug destroys an + // EXTRA attached backend per change (it attaches B1 in the rebuild, then + // frees it in onControlChanged before attaching B2) β€” this counter is the + // discriminator that plain live/ctor counts can't be (probe churn). + mm::LedHwBlock block; + explicit SwapMock(mm::LedHwBlock b) : block(b) { live++; } + // owner_ (protected, set by attach()) is non-null only for a backend the driver actually selected; + // a buildPeripheralOptions() probe is make()/delete'd without attach(), so it destructs with a null + // owner_. Counting attached destructions isolates real backend churn from probe churn. For an ATTACHED + // backend being freed, also snapshot whether the quiesce hook had already fired β€” the ordering proof + // the swap-ordering test reads (the worker must be stopped BEFORE the backend it dereferences is freed). + ~SwapMock() override { + live--; + if (owner_) { attachedDtors++; g_hookFiredBeforeLastDtor = (g_hookFires > 0); } + } + + uint8_t lanesAvailable() const override { return 8; } + bool powerOfTwoBus() const override { return true; } + bool loopbackFullWidth() const override { return false; } + bool supportsPinExpander() const override { return false; } + const char* initFailMsg() const override { return "mock init failed"; } + mm::LedHwBlock hwBlock() const override { return block; } + + void addBusControls(mm::ControlList&) override {} + bool busControlTriggersBuild(const char*) const override { return false; } + void recordBusPins() override {} + bool extraBusPinsCurrent() const override { return true; } + const char* validateBusPins(const uint16_t*, uint8_t) const override { return nullptr; } + const char* validateBusFatal() const override { return nullptr; } + uint16_t clockPinForBus() const override { return 99; } + + bool busInit(size_t frameBytes, bool) override { cap_ = frameBytes; buf_.assign(frameBytes, 0); return true; } + uint8_t* busBuffer(uint8_t i) override { return (i == 0 && !buf_.empty()) ? buf_.data() : nullptr; } + size_t busCapacity() const override { return cap_; } + bool busTransmit(uint8_t, size_t) override { transmits_++; return true; } + bool busWait(uint8_t, uint32_t) override { return true; } + uint32_t busLastTransmitUs() const override { return 0; } + void busDeinit() override { cap_ = 0; buf_.clear(); } + mm::platform::RmtLoopbackResult busLoopback(const uint8_t*, size_t, size_t, uint8_t) override { return {}; } + + size_t transmits_ = 0; +private: + std::vector buf_; + size_t cap_ = 0; +}; + +// The peripheral registry is a static set built at static-init in production. On desktop the three +// real backends (i80/MoonI80/Parlio) DO register (their headers compile into this binary) but report +// 0 lanes, so they fill registry slots while being filtered out of the option list β€” leaving no room +// for two test mocks (kMaxPeripherals == 4). So a swap test SAVES the registry, installs exactly its +// two mocks, runs, and RESTORES it β€” scoped, and it can't perturb any other test's view of the set. +struct RegistryScope { + mm::ParallelLedDriver::PeripheralEntry saved[mm::ParallelLedDriver::kMaxPeripherals]; + uint8_t savedCount; + RegistryScope() { + savedCount = mm::ParallelLedDriver::peripheralRegistryCount_; + for (uint8_t i = 0; i < savedCount; i++) saved[i] = mm::ParallelLedDriver::peripheralRegistry_[i]; + mm::ParallelLedDriver::peripheralRegistryCount_ = 0; + // "swapA" (Parlio block) and "swapB" (LcdCam block) model two distinct peripherals. + mm::ParallelLedDriver::registerPeripheral("swapA", []() -> mm::LedPeripheral* { + return new SwapMock(mm::LedHwBlock::Parlio); + }); + mm::ParallelLedDriver::registerPeripheral("swapB", []() -> mm::LedPeripheral* { + return new SwapMock(mm::LedHwBlock::LcdCam); + }); + } + ~RegistryScope() { + mm::ParallelLedDriver::peripheralRegistryCount_ = savedCount; + for (uint8_t i = 0; i < savedCount; i++) mm::ParallelLedDriver::peripheralRegistry_[i] = saved[i]; + } +}; + +// Drive the Scheduler control-change sequence for the `peripheral` Select: write the new index through +// the bound control pointer (as applyControlValue does), rebuild controls (swap #1, binds new backend), +// then fire onControlChanged (must be a no-op swap, not a second free-and-rebuild). +void changePeripheralTo(mm::ParallelLedDriver& d, uint8_t index) { + int i = mm::test::controlIndex(d, "peripheral"); + REQUIRE(i >= 0); + *static_cast(d.controls()[static_cast(i)].ptr) = index; + d.rebuildControls(); + d.onControlChanged("peripheral"); +} + +} // namespace + +TEST_CASE("ParallelLedDriver: a peripheral swap does not double-free or dangle the control list") { + RegistryScope registry; // install swapA/swapB, restore the real set on scope exit + const int liveBefore = SwapMock::live; + + { + mm::ParallelLedDriver d; + mm::Buffer src; + mm::Correction corr; + // Fresh driver seeds the first registered backend (swapA). Wire a working config. + std::strcpy(d.pins, "1,2"); + REQUIRE(src.allocate(64, 3) == true); + mm::test::rebuildFromPreset(corr, 255, mm::test::PresetOrder::GRB); + d.defineControls(); + d.setSourceBuffer(&src); + d.correctionForTest() = corr; + d.applyState(); + + // Exactly ONE backend is live for a driver holding a single peripheral. + CHECK(SwapMock::live == liveBefore + 1); + + // Resolve labels β†’ board-filtered indices via the driver's own option list (order is registry + // order, which the test does not hardcode). A Select stores its options in aux and its option + // COUNT in max (hi = max-1), the shape defineDriverControls uses for the peripheral Select. + int aIdx = -1, bIdx = -1; + const auto& cs = d.controls(); + int selIdx = mm::test::controlIndex(d, "peripheral"); + REQUIRE(selIdx >= 0); + const char* const* opts = reinterpret_cast(cs[static_cast(selIdx)].aux); + const int optCount = cs[static_cast(selIdx)].max; // addSelect stores optionCount in max + for (int k = 0; k < optCount; k++) { + if (opts[k] && std::strcmp(opts[k], "swapA") == 0) aIdx = k; + if (opts[k] && std::strcmp(opts[k], "swapB") == 0) bIdx = k; + } + REQUIRE(aIdx >= 0); + REQUIRE(bIdx >= 0); + + // Swap A β†’ B. The bug: rebuildControls() swaps in B1 (binding its members into the control list), + // then onControlChanged swaps AGAIN β€” freeing B1 and building B2 β€” so the control list dangles into + // freed B1. The tell is the CONSTRUCTION count: the buggy path builds TWO backends for one change + // (B1 orphaned + B2), the fixed path builds exactly ONE (onControlChanged is a no-op swap). live + // count alone can't see it (both paths net one live backend); `built` delta is the discriminator. + int dtorsBefore = SwapMock::attachedDtors; + changePeripheralTo(d, static_cast(bIdx)); + // Aβ†’B destroys exactly ONE attached backend (the outgoing A). The double-swap bug destroys TWO + // (A replaced by B1 in the rebuild, then B1 replaced by B2 in onControlChanged) β€” so a delta > 1 + // is the bug's fingerprint. This is what live/ctor counts miss: probe churn hides the ctor delta, + // and net-live is 1 either way; only counting ATTACHED destructions isolates the extra free. + CHECK(SwapMock::attachedDtors - dtorsBefore == 1); + CHECK(SwapMock::live == liveBefore + 1); + // busInit succeeds (a memory buffer, not an inert desktop stub), so the bus comes up and the + // inited_-gated hwBlock() reports B's block (LcdCam) β€” proving the LIVE backend is B, not a + // dangling A: if the double-swap freed the rebuild-bound backend, hwBlock() would read freed memory. + CHECK(d.hwBlock() == mm::LedHwBlock::LcdCam); + + // The driver must still run: a tick after the swap encodes+transmits through the LIVE backend, + // not a freed one. If the control list dangled, this is a use-after-free (ASan/TSan would catch; + // the plain assert catches the "transmitted through a stale object" case). + d.tick20ms(); + d.tick(); // one render tick β€” reaches the live backend's busTransmit + + // Swap back B β†’ A, then A β†’ B again: repeated swaps must stay balanced (no accumulation), and + // hwBlock() tracks the live backend each time (A = Parlio, B = LcdCam). + changePeripheralTo(d, static_cast(aIdx)); + CHECK(SwapMock::live == liveBefore + 1); + CHECK(d.hwBlock() == mm::LedHwBlock::Parlio); + changePeripheralTo(d, static_cast(bIdx)); + CHECK(SwapMock::live == liveBefore + 1); + CHECK(d.hwBlock() == mm::LedHwBlock::LcdCam); + } + + // Driver destroyed β†’ its owned backend freed β†’ back to the starting live count (no leak). + CHECK(SwapMock::live == liveBefore); +} + +// Regression (pre-merge Reviewer BLOCKER): a LIVE peripheral swap frees the old backend, and the core-1 +// encode worker dereferences that backend (busBuffer/busTransmit) β€” so swapPeripheral MUST stop the +// worker (fire the quiesce-render hook) BEFORE deleting the backend, or it is a use-after-free, the same +// class the structural-mutation quiesce fixes. This pins the ORDERING, not merely that the hook fired: +// SwapMock's destructor snapshots (into g_hookFiredBeforeLastDtor) whether the quiesce hook had already +// fired at the moment the outgoing attached backend is freed. A swap that deleted the backend BEFORE +// quiescing would leave that flag false even though g_hookFires ends up > 0. +TEST_CASE("ParallelLedDriver: a live peripheral swap quiesces the render worker before freeing the backend") { + RegistryScope registry; + g_hookFires = 0; + g_hookFiredBeforeLastDtor = false; + + // The hook records each fire; SwapMock's destructor records whether it had fired before an attached + // backend was freed β€” the ordering proof. + mm::MoonModule::setQuiesceRenderHook([] { g_hookFires++; }); + + { + mm::ParallelLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.pins, "1,2"); + REQUIRE(src.allocate(64, 3) == true); + mm::test::rebuildFromPreset(corr, 255, mm::test::PresetOrder::GRB); + d.defineControls(); + d.setSourceBuffer(&src); + d.correctionForTest() = corr; + d.applyState(); + + const auto& cs = d.controls(); + int selIdx = mm::test::controlIndex(d, "peripheral"); + REQUIRE(selIdx >= 0); + const char* const* opts = reinterpret_cast(cs[static_cast(selIdx)].aux); + const int optCount = cs[static_cast(selIdx)].max; + int bIdx = -1; + for (int k = 0; k < optCount; k++) if (opts[k] && std::strcmp(opts[k], "swapB") == 0) bIdx = k; + REQUIRE(bIdx >= 0); + + // Reset the fire count to ZERO immediately before the swap so the dtor's "had the hook fired?" + // snapshot reflects ONLY this swap β€” not an earlier fire from applyState/rebuild. Otherwise any + // prior fire would make g_hookFiredBeforeLastDtor true even if THIS swap freed A before quiescing. + g_hookFires = 0; + g_hookFiredBeforeLastDtor = false; + changePeripheralTo(d, static_cast(bIdx)); // the live swap: frees the old backend (A) + // The swap fired the quiesce hook at all... + CHECK(g_hookFires > 0); + // ...AND it fired BEFORE the outgoing backend was freed. This is the ordering the finding pins: + // the SwapMock dtor recorded whether the hook had fired (this swap) when the attached backend (A) + // was destroyed. A swap that deleted the backend first would leave this false despite the hook + // firing later in the same swap. + CHECK(g_hookFiredBeforeLastDtor); + } + + mm::MoonModule::setQuiesceRenderHook(nullptr); +} diff --git a/test/unit/light/unit_ParlioLedDriver.cpp b/test/unit/light/unit_ParlioLedDriver.cpp index 335734c0..c1e36f2d 100644 --- a/test/unit/light/unit_ParlioLedDriver.cpp +++ b/test/unit/light/unit_ParlioLedDriver.cpp @@ -20,11 +20,19 @@ // The one behavioural difference from the LCD driver pinned below: Parlio has // NO exactly-8-pins rule β€” 1..8 lanes are all valid (it takes the data GPIOs // directly, no all-lanes-required i80 bus). +// +// mm::ParallelLedDriver is the ONE registered driver; this file drives it with an +// injected mm::ParlioPeripheral backend, the backend this header defines and registers +// under the "Parlio" peripheral label. namespace { -void wire(mm::ParlioLedDriver& d, mm::Buffer& src, mm::Correction& corr, +// The peripheral is declared BEFORE the driver at every call site (see this helper's +// parameter order) so it outlives the driver β€” setPeripheralForTest borrows, it does +// not own (see unit_ParallelLedDriver_doublebuffer.cpp's wire()). +void wire(mm::ParallelLedDriver& d, mm::ParlioPeripheral& peripheral, mm::Buffer& src, mm::Correction& corr, mm::nrOfLightsType lights) { + d.setPeripheralForTest(&peripheral); // Pins default to UNSET now (the "default only when it cannot do harm" rule β€” // the user solders the strand to its own GPIOs), so a fresh driver idles until // configured. These slicing/frame tests exercise the lane logic, not the @@ -54,12 +62,13 @@ size_t expectFrame(mm::nrOfLightsType maxLights, uint8_t outCh, uint8_t slotByte // Three lanes (Parlio accepts any 1..8 count) slice the buffer consecutively; // the frame is sized by the LONGEST lane. TEST_CASE("ParlioLedDriver slices lanes and sizes the frame by the longest") { - mm::ParlioLedDriver d; + mm::ParlioPeripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; std::strcpy(d.pins, "36,37,38"); std::strcpy(d.ledsPerPin, "50,20,20"); - wire(d, src, corr, 90); + wire(d, peripheral, src, corr, 90); REQUIRE(d.laneCount() == 3); CHECK(d.laneLightCount(0) == 50); @@ -75,10 +84,11 @@ TEST_CASE("ParlioLedDriver slices lanes and sizes the frame by the longest") { // Empty ledsPerPin (the default) splits evenly over the 8 lanes β€” shared PinList // semantics, same as the RMT/LCD drivers. TEST_CASE("ParlioLedDriver even split over 8 lanes") { - mm::ParlioLedDriver d; + mm::ParlioPeripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 256); // ledsPerPin empty (default) = even split + wire(d, peripheral, src, corr, 256); // ledsPerPin empty (default) = even split REQUIRE(d.laneCount() == 8); CHECK(d.laneLightCount(0) == 32); @@ -92,10 +102,11 @@ TEST_CASE("ParlioLedDriver accepts any lane count from 1 to 8") { mm::Correction corr; mm::test::rebuildFromPreset(corr, 255, mm::test::PresetOrder::GRB); for (const char* pinList : {"36", "36,37", "36,37,38,39,40", "36,37,38,39,40,41,42,43"}) { - mm::ParlioLedDriver d; + mm::ParlioPeripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; std::strcpy(d.pins, pinList); - wire(d, src, corr, 64); + wire(d, peripheral, src, corr, 64); // count the commas+1 to know the expected lane count uint8_t expected = 1; for (const char* p = pinList; *p; p++) if (*p == ',') expected++; @@ -110,19 +121,21 @@ TEST_CASE("ParlioLedDriver accepts any lane count from 1 to 8") { // 9..16 pins are accepted (Parlio drives 1..16, the 16-bit bus); more than 16 is rejected. TEST_CASE("ParlioLedDriver accepts 9..16 pins, rejects more than 16") { - mm::ParlioLedDriver d; + mm::ParlioPeripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; { // 12 pins β†’ 12 lanes, valid (the 16-bit bus) std::strcpy(d.pins, "1,2,3,4,5,6,7,8,9,10,11,12"); - wire(d, src, corr, 64); + wire(d, peripheral, src, corr, 64); CHECK(d.laneCount() == 12); CHECK(d.severity() != mm::MoonModule::Severity::Error); // valid β†’ info, not error } { // 17 pins β†’ rejected (over the 16-lane cap) - mm::ParlioLedDriver d2; + mm::ParlioPeripheral peripheral2; + mm::ParallelLedDriver d2; std::strcpy(d2.pins, "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17"); - wire(d2, src, corr, 64); + wire(d2, peripheral2, src, corr, 64); CHECK(d2.laneCount() == 0); CHECK(d2.status() != nullptr); } @@ -135,19 +148,21 @@ TEST_CASE("ParlioLedDriver 16-lane frame doubles the byte size (16-bit bus)") { mm::Buffer src; mm::Correction corr; { // 8 lanes Γ— 50 lights β†’ 8-bit bus (slotBytes = 1) - mm::ParlioLedDriver d; + mm::ParlioPeripheral peripheral; + mm::ParallelLedDriver d; std::strcpy(d.pins, "1,2,3,4,5,6,7,8"); std::strcpy(d.ledsPerPin, "50,50,50,50,50,50,50,50"); - wire(d, src, corr, 400); + wire(d, peripheral, src, corr, 400); REQUIRE(d.laneCount() == 8); CHECK(d.maxLaneLights() == 50); CHECK(d.frameBytes() == expectFrame(50, 3, 1)); } { // 16 lanes Γ— 50 lights β†’ 16-bit bus (slotBytes = 2), same per-lane lights, DOUBLE bytes - mm::ParlioLedDriver d; + mm::ParlioPeripheral peripheral; + mm::ParallelLedDriver d; std::strcpy(d.pins, "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16"); std::strcpy(d.ledsPerPin, "50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50"); - wire(d, src, corr, 800); + wire(d, peripheral, src, corr, 800); REQUIRE(d.laneCount() == 16); CHECK(d.maxLaneLights() == 50); CHECK(d.frameBytes() == expectFrame(50, 3, 2)); // 16-bit slots β†’ 2 bytes/slot @@ -158,11 +173,12 @@ TEST_CASE("ParlioLedDriver 16-lane frame doubles the byte size (16-bit bus)") { // An RGBβ†’RGBW preset toggle grows the frame (32 vs 24 slot bytes per light). TEST_CASE("ParlioLedDriver frame grows on RGBW preset") { - mm::ParlioLedDriver d; + mm::ParlioPeripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; std::strcpy(d.ledsPerPin, "50,50"); - wire(d, src, corr, 100); + wire(d, peripheral, src, corr, 100); CHECK(d.frameBytes() == expectFrame(50, 3)); // The driver owns its Correction, so mutate that copy (not the external one). @@ -184,13 +200,14 @@ TEST_CASE("ParlioLedDriver frame grows on RGBW preset") { // changes. Mirrors the platform constant. TEST_CASE("ParlioLedDriver frame at the Parlio single-transfer ceiling (byte limit, channel-relative)") { constexpr size_t kParlioMaxTransferBytes = 0x7FFFF / 8; // 65535, matches platform_esp32_parlio.cpp - mm::ParlioLedDriver d; + mm::ParlioPeripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; // 896 RGB lights/lane β€” the HW-tested config β€” FITS one transfer. std::strcpy(d.pins, "20,21,22,23,24,25,26,27"); std::strcpy(d.ledsPerPin, "896,896,896,896,896,896,896,896"); - wire(d, src, corr, 896 * 8); + wire(d, peripheral, src, corr, 896 * 8); CHECK(d.maxLaneLights() == 896); CHECK(d.frameBytes() == expectFrame(896, 3)); CHECK(d.frameBytes() <= kParlioMaxTransferBytes); // fits one Parlio transfer @@ -204,11 +221,12 @@ TEST_CASE("ParlioLedDriver frame at the Parlio single-transfer ceiling (byte lim // A bad pin list idles the driver with the parse literal in the status; fixing it recovers. TEST_CASE("ParlioLedDriver bad pins β†’ status error β†’ recovery") { - mm::ParlioLedDriver d; + mm::ParlioPeripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; std::strcpy(d.pins, "36,nope"); - wire(d, src, corr, 64); + wire(d, peripheral, src, corr, 64); CHECK(d.laneCount() == 0); CHECK(d.frameBytes() == 0); @@ -227,9 +245,11 @@ TEST_CASE("ParlioLedDriver bad pins β†’ status error β†’ recovery") { // GPIO. (wire() back-fills empty pins for the slicing cases, so this one wires // the buffer directly to keep pins empty.) TEST_CASE("ParlioLedDriver with the empty default pins idles cleanly") { - mm::ParlioLedDriver d; + mm::ParlioPeripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; + d.setPeripheralForTest(&peripheral); REQUIRE(d.pins[0] == '\0'); // the empty default, not a bench guess REQUIRE(src.allocate(64, 3)); mm::test::rebuildFromPreset(corr, 255, mm::test::PresetOrder::GRB); @@ -246,10 +266,11 @@ TEST_CASE("ParlioLedDriver with the empty default pins idles cleanly") { // A 0Γ—0Γ—0 grid is a clean idle: zero counts, zero frame, no crash. TEST_CASE("ParlioLedDriver tolerates a zero-light buffer") { - mm::ParlioLedDriver d; + mm::ParlioPeripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; - wire(d, src, corr, 0); + wire(d, peripheral, src, corr, 0); CHECK(d.laneCount() == 8); // the default 8 pins parse fine CHECK(d.maxLaneLights() == 0); @@ -265,20 +286,24 @@ TEST_CASE("ParlioLedDriver tick is crash-safe for every pin configuration") { mm::test::rebuildFromPreset(corr, 255, mm::test::PresetOrder::GRB); SUBCASE("single pin, populated grid") { - mm::ParlioLedDriver d; mm::Buffer src; + mm::ParlioPeripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; std::strcpy(d.pins, "36"); - wire(d, src, corr, 64); + wire(d, peripheral, src, corr, 64); d.tick(); } SUBCASE("multi-pin even split") { - mm::ParlioLedDriver d; mm::Buffer src; + mm::ParlioPeripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; std::strcpy(d.pins, "36,37,38"); - wire(d, src, corr, 90); + wire(d, peripheral, src, corr, 90); REQUIRE(d.laneCount() == 3); d.tick(); } SUBCASE("tick before any buffer is wired") { - mm::ParlioLedDriver d; + mm::ParlioPeripheral peripheral; + mm::ParallelLedDriver d; + d.setPeripheralForTest(&peripheral); d.defineControls(); d.tick(); } @@ -287,9 +312,11 @@ TEST_CASE("ParlioLedDriver tick is crash-safe for every pin configuration") { // setup/release cycles leave no residue (status clean, ASAN-checked heap). TEST_CASE("ParlioLedDriver setup/release is repeatable") { - mm::ParlioLedDriver d; + mm::ParlioPeripheral peripheral; + mm::ParallelLedDriver d; mm::Buffer src; mm::Correction corr; + d.setPeripheralForTest(&peripheral); src.allocate(64, 3); mm::test::rebuildFromPreset(corr, 255, mm::test::PresetOrder::GRB); std::strcpy(d.pins, "20,21,22,23,24,25,26,27"); // pins now default UNSET @@ -307,7 +334,9 @@ TEST_CASE("ParlioLedDriver setup/release is repeatable") { // loopbackRxPin is bound always, visible only while loopbackTest is on. TEST_CASE("ParlioLedDriver loopbackRxPin tracks the loopbackTest toggle") { - mm::ParlioLedDriver d; + mm::ParlioPeripheral peripheral; + mm::ParallelLedDriver d; + d.setPeripheralForTest(&peripheral); d.defineControls(); bool found = false; for (uint8_t i = 0; i < d.controls().count(); i++) { @@ -325,7 +354,9 @@ TEST_CASE("ParlioLedDriver loopbackRxPin tracks the loopbackTest toggle") { // contract is host-testable here via the shared helper (toggles loopbackTest both // ways and asserts the control stays bound while flipping visibility). TEST_CASE("ParlioLedDriver loopbackTxPin tracks the loopbackTest toggle") { - mm::ParlioLedDriver d; + mm::ParlioPeripheral peripheral; + mm::ParallelLedDriver d; + d.setPeripheralForTest(&peripheral); d.defineControls(); auto setTest = [&](bool on) { mm::test::setControlValue(d, "loopbackTest", on); diff --git a/test/unit/light/unit_PreviewDriver.cpp b/test/unit/light/unit_PreviewDriver.cpp index e365aae4..64020785 100644 --- a/test/unit/light/unit_PreviewDriver.cpp +++ b/test/unit/light/unit_PreviewDriver.cpp @@ -436,6 +436,64 @@ TEST_CASE("PreviewDriver gates the next frame on the buffered send draining (ada mm::platform::setTestNowMs(0); } +// ADAPTIVE RESOLUTION RECOVERY: the downsample coarsens ADDITIVELY (downscale_++ on slow frames, a +// gentle anti-stall) but refines MULTIPLICATIVELY (halve toward 1 on a run of clean frames). So a grid +// that briefly coarsened on a slow link snaps back to full resolution in ~log2 refine events, not one +// step per unit β€” the fix for a small grid taking ~10 s to reach full detail. This pins the halving so +// the recovery can't silently regress to the old linear crawl. +TEST_CASE("PreviewDriver refines resolution multiplicatively (fast recovery to full res)") { + mm::GridLayout g; g.width = 16; g.height = 16; g.depth = 1; // 256 lights, trivially full-res-able + PreviewRig rig(&g); + + uint32_t t = 1000; + auto tickSlow = [&] { rig.cap.bufferedDrains = 5; t += 100; mm::platform::setTestNowMs(t); rig.preview->tick(); }; + auto tickFast = [&] { rig.cap.bufferedDrains = 0; t += 100; mm::platform::setTestNowMs(t); rig.preview->tick(); }; + + // Drive it coarse: a run of slow frames coarsens downscale_ well above 1 (additive +1 per event). + for (int i = 0; i < 40; i++) tickSlow(); + const mm::nrOfLightsType coarsened = rig.preview->downscaleForTest(); + REQUIRE(coarsened > 1); // it did downsample under the slow link + + // Now the link is prompt. Count how many refine EVENTS (clean-streak completions) it takes to reach + // full res. Multiplicative halving needs ~log2(coarsened) events, far fewer than (coarsened-1) linear + // steps. kUpscaleAfterFast clean frames per event; bound the loop generously and assert it converged. + int refineEvents = 0; + mm::nrOfLightsType prev = coarsened; + for (int i = 0; i < 200 && rig.preview->downscaleForTest() > 1; i++) { + tickFast(); + const mm::nrOfLightsType now = rig.preview->downscaleForTest(); + if (now < prev) { refineEvents++; CHECK(now <= (prev + 1) / 2); prev = now; } // each event at least halves + } + CHECK(rig.preview->downscaleForTest() == 1); // reached full resolution + // log2(64 max) = 6 events ceiling; a real coarsened value needs far fewer. Linear would be up to 63. + CHECK(refineEvents <= 6); + + mm::platform::setTestNowMs(0); +} + +// RE-ANCHOR ON REBUILD: a link-struggle coarsening must NOT carry across a geometry change and hold a +// now-fitting grid coarse. A rebuild resets downscale_ to 1, so the memory/display cap alone sets the +// stride for the new grid β€” a grid that fits renders at full res immediately, no inherited ramp. (This +// is the "add a small grid β†’ stuck at 4 blobs for ~10 s because a prior config had coarsened" fix.) +TEST_CASE("PreviewDriver re-anchors resolution on a geometry rebuild (no inherited coarsening)") { + mm::GridLayout g; g.width = 16; g.height = 16; g.depth = 1; + PreviewRig rig(&g); + + uint32_t t = 1000; + auto tickSlow = [&] { rig.cap.bufferedDrains = 5; t += 100; mm::platform::setTestNowMs(t); rig.preview->tick(); }; + + // Coarsen it under a slow link. + for (int i = 0; i < 40; i++) tickSlow(); + REQUIRE(rig.preview->downscaleForTest() > 1); // it coarsened + + // A rebuild (a resize, or just re-preparing the same fitting grid) must re-anchor to full res: the + // 16Γ—16 (256 lights) is well under the cap, so with downscale_ reset it renders at stride 1. + rig.preview->applyState(); // prepare() re-anchors downscale_ + CHECK(rig.preview->downscaleForTest() == 1); // did NOT inherit the stale coarsening + + mm::platform::setTestNowMs(0); +} + // USE-AFTER-FREE GUARD: a geometry rebuild (resize) frees+reallocs the producer buffer, so any // in-flight buffered send (which holds a pointer into it) MUST be cancelled in prepare before // the buffer goes away β€” else drainPreviewSend would read freed memory. diff --git a/test/unit/light/unit_RmtLedDriver_lifecycle.cpp b/test/unit/light/unit_RmtLedDriver_lifecycle.cpp index 338fc6c8..7c34e262 100644 --- a/test/unit/light/unit_RmtLedDriver_lifecycle.cpp +++ b/test/unit/light/unit_RmtLedDriver_lifecycle.cpp @@ -46,6 +46,49 @@ TEST_CASE("RmtLedDriver sizes the symbol buffer in prepare") { CHECK(d.symbolCapacity() >= static_cast(64) * 3 * 8); } +// The resting status is "driving N of M lights" after a build, shown by DEFAULT (the way MoonLed does) β€” +// not only after the user touches a control. prepare() re-asserts it after the full build (pins + buffer +// + counts settled), so a driver that built cleanly always advertises its consumption. This also +// overwrites any stale transient (e.g. a prior loopback verdict), which must NOT linger as the resting +// status once the driver is driving lights. +TEST_CASE("RmtLedDriver shows 'driving N of M' as the resting status after a build") { + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.pins, "16"); + std::strcpy(d.ledsPerPin, "64"); + wire(d, src, corr, 256); // defineControls + setSourceBuffer + applyState (the build) + + REQUIRE(d.status() != nullptr); + CHECK(std::strstr(d.status(), "driving 64 of 256") != nullptr); + CHECK(d.severity() != mm::MoonModule::Severity::Error); + + // A rebuild (the prepareTree sweep β€” a resize, an enable) re-asserts it, so the resting status is + // stable across rebuilds and never silently blanks. + d.applyState(); + REQUIRE(d.status() != nullptr); + CHECK(std::strstr(d.status(), "driving 64 of 256") != nullptr); +} + +// The symbol buffer sizes to what the pins CLOCK OUT (txLightCount_), NOT the window. A small strip on +// one pin (ledsPerPin 64) inside a huge grid (window = all 5740 lights) must reserve symbols for 64, not +// 5740 β€” else it tries to alloc ~550 KB it never encodes, the alloc fails on a small-heap board, and the +// strip goes dark even though only 64 lights were wanted (the bug this pins; ParallelLedDriver already +// sizes its frame to the driven count, RmtLed did not). ledsPerPin caps the pin; tick() only encodes 64. +TEST_CASE("RmtLedDriver sizes symbols to the driven lights, not the whole window") { + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.pins, "16"); + std::strcpy(d.ledsPerPin, "64"); // one pin, 64 lights β€” the physical 8Γ—8 strip + wire(d, src, corr, 5740); // but a 70Γ—82 grid in the buffer (count defaults to all) + + REQUIRE(d.symbolBuffer() != nullptr); // allocated (64 lights fits easily) + CHECK(d.symbolCapacity() >= static_cast(64) * 3 * 8); // holds the 64 it encodes + // The window is 5740, but the buffer must NOT be sized for it (that was the ~550 KB over-alloc). + CHECK(d.symbolCapacity() < static_cast(5740) * 3 * 8); +} + TEST_CASE("RmtLedDriver keeps the symbol buffer across a rebuild (reinit must not free it)") { // The regression: prepare() does resizeSymbols() THEN reinit(), and a // bad reinit()->deinit() freed symbols_ right after it was allocated, so the diff --git a/web-installer/assets/app-store-badge.svg b/web-installer/assets/app-store-badge.svg new file mode 100644 index 00000000..072b425a --- /dev/null +++ b/web-installer/assets/app-store-badge.svg @@ -0,0 +1,46 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web-installer/assets/google-play-badge.png b/web-installer/assets/google-play-badge.png new file mode 100644 index 00000000..131f3aca Binary files /dev/null and b/web-installer/assets/google-play-badge.png differ diff --git a/web-installer/assets/home-assistant-icon.png b/web-installer/assets/home-assistant-icon.png new file mode 100644 index 00000000..39f0c14e Binary files /dev/null and b/web-installer/assets/home-assistant-icon.png differ diff --git a/web-installer/deviceModels.json b/web-installer/deviceModels.json index b3df6f67..0532d633 100644 --- a/web-installer/deviceModels.json +++ b/web-installer/deviceModels.json @@ -715,10 +715,11 @@ } }, { - "type": "ParlioLedDriver", - "id": "ParlioLed", + "type": "ParallelLedDriver", + "id": "ParallelLed", "parent_id": "Drivers", "controls": { + "peripheral": "Parlio", "pins": "21", "loopbackTxPin": 46, "loopbackRxPin": 47 @@ -867,10 +868,11 @@ } }, { - "type": "MultiPinLedDriver", - "id": "MultiPinLed", + "type": "ParallelLedDriver", + "id": "ParallelLed", "parent_id": "Drivers", "controls": { + "peripheral": "i80", "pins": "47,21,14,9,8,16,15,7,1,2,42,41,40,39,38,48", "clockPin": 5, "dcPin": 6 @@ -925,10 +927,11 @@ } }, { - "type": "MultiPinLedDriver", - "id": "MultiPinLed", + "type": "ParallelLedDriver", + "id": "ParallelLed", "parent_id": "Drivers", "controls": { + "peripheral": "i80", "pins": "47,48,21,38,14,39,13,40,12,41,11,42,10,2,3,1", "clockPin": 8, "dcPin": 9 @@ -984,10 +987,11 @@ } }, { - "type": "ParlioLedDriver", - "id": "ParlioLed", + "type": "ParallelLedDriver", + "id": "ParallelLed", "parent_id": "Drivers", "controls": { + "peripheral": "Parlio", "pins": "20,21,22,23,24,25,26,27" } }, @@ -1054,10 +1058,11 @@ } }, { - "type": "ParlioLedDriver", - "id": "ParlioLed", + "type": "ParallelLedDriver", + "id": "ParallelLed", "parent_id": "Drivers", "controls": { + "peripheral": "Parlio", "pins": "21,20,25,5,22,23,24,27" } }, @@ -1231,7 +1236,7 @@ ] }, { - "name": "hpwit shift-register board", + "name": "hpwit shift-register 48", "chip": "ESP32-S3", "firmwares": [ "esp32s3-n16r8" @@ -1246,20 +1251,32 @@ "type": "System", "id": "System", "controls": { - "deviceModel": "hpwit shift-register board" + "deviceModel": "hpwit shift-register 48" } }, { - "type": "MultiPinLedDriver", - "id": "MultiPinLed", + "type": "PanelsLayout", + "id": "Panels", + "parent_id": "Layouts", + "controls": { + "horizontalPanels": 8, + "verticalPanels": 6, + "panelWidth": 16, + "panelHeight": 16, + "snake": true + } + }, + { + "type": "ParallelLedDriver", + "id": "ParallelLed", "parent_id": "Drivers", "controls": { + "peripheral": "MoonI80", "pins": "9,10,12,8,18,17", "pinExpander": true, "latchPin": 46, "clockPin": 3, - "ledsPerPin": "96", - "dcPin": 13, + "ledsPerPin": "256", "doubleBuffer": false } }, @@ -1292,17 +1309,32 @@ } }, { - "type": "MultiPinLedDriver", - "id": "MultiPinLed", + "type": "PanelsLayout", + "id": "Panels", + "parent_id": "Layouts", + "controls": { + "horizontalPanels": 5, + "verticalPanels": 3, + "panelWidth": 16, + "panelHeight": 16, + "snake": true + } + }, + { + "type": "ParallelLedDriver", + "id": "ParallelLed", "parent_id": "Drivers", "controls": { + "peripheral": "MoonI80", "pins": "9,10", "pinExpander": true, "latchPin": 46, "clockPin": 3, - "ledsPerPin": "96", - "dcPin": 21, - "doubleBuffer": false + "ledsPerPin": "256", + "doubleBuffer": true, + "loopbackTest": false, + "loopbackStrand": 8, + "loopbackRxPin": 16 } }, { diff --git a/web-installer/index.html b/web-installer/index.html index cda13b3f..1d9d9b4d 100644 --- a/web-installer/index.html +++ b/web-installer/index.html @@ -30,7 +30,7 @@ there's no flash-of-visible-content concern. -->

projectMM Installer - ?

Plug in your ESP32. Pick a release + device. Flash. Get the device on your network. Open it in a browser.

@@ -201,11 +201,42 @@

projectMM Installer