Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
26 changes: 24 additions & 2 deletions docs/MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

---

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Derived>` 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.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Loading
Loading