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
13 changes: 13 additions & 0 deletions docs/MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ projectMM ships **no migration code**: the persistence layer is robust by defaul

## Unreleased (`next-iteration`)

### `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.

| Old | New |
|---|---|
| control `sync` (Select: `off`/`send`/`receive`) | `mode` (Select: `local audio`/`receive network`/`simulate`) + `send audio` (a switch, Local mode only) |
| control `simulate` (Select, 5 options incl. a mic-fill-on-silence mode) | `simulate` (Select: 2 options) — used only when `mode` is Simulate |

**Action: re-set `mode` (and `send audio`) if you had `sync` on `send` or `receive`; re-set `simulate` if you had chosen a non-default option.**

`sync` and the old `simulate` value read as absent → ignored, so `mode` takes its default (**Local audio**) and `send audio` its default (**off**). A device that was on `sync=receive` therefore comes up as Local audio — set `mode` to Receive network again. One that broadcast (`sync=send`) comes up not broadcasting — turn `send audio` on. The five-option `simulate` collapsed to two, so a device on one of the dropped options (e.g. the mic-fill-on-silence mode, a removed capability) takes the new default; re-pick if needed. Receive network and every sync control exist only on network-capable targets.

### `MoonLedDriver`: `forceRing` → `useRing`, and the ring's geometry is now settable (2026-07-17)

The pin-expander path selector was a three-option Select (`auto` / `ring` / `wholeFrame`) named for a *diagnostic override*. The auto-router is gone — at the size the expander exists for (48 strands × 256 lights) a whole frame never fits internal DMA RAM, so "auto" had exactly one right answer while presenting itself as a choice, and its silent fallback hid which path was actually running. What remains is the honest question, as a switch:
Expand Down
13 changes: 7 additions & 6 deletions docs/backlog/backlog-light.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,12 +197,6 @@ Today a "light" is a point at a static coordinate with a colour. A **moving head

**Sub-item — per-emitter targets, and fixing the Yellow/UV RGB-synthesis placeholder.** `Correction::apply()` today synthesizes every non-RGB emitter (White, WarmWhite, Yellow, UV) from RGB via the one `whiteMode` gate. That conflates two different physics: **White/WarmWhite are broadband illumination** with a real achromatic basis (`min(R,G,B)` is a sound approximation — warm vs cold differ only in phosphor CCT a byte value can't express), but **Yellow/Amber (~590 nm) and UV (~400 nm) are saturated/out-of-gamut emitters with no honest RGB pre-image.** Yellow's `min(R,G)` stand-in reads greener than a true amber die AND fires on far too much (any red+green content — yellows, whites, skin tones — muddies the fixture); UV's blue-excess is an eyeball hack. Amber is a *real, common, targetable* emitter (RGBA / RGBAW PARs ship a dedicated ~590 nm die), so the right model is an **effect that targets amber DIRECTLY** (a typed attribute), not RGB-and-synthesize. The fixture model is where that lands. **Decision to make when built:** whether Yellow/UV should default to **off** (0, like `whiteMode::None`) until an effect drives them — more honest than always firing a wrong approximation — vs. keeping the "light it up to eyeball the wiring" placeholder. **Practical test to run then:** on a real RGBA/RGBAW+UV PAR, compare the synthesized amber against a directly-driven amber die (does `min(R,G)` look acceptably yellow, or muddy?), and confirm the White subtraction under `Accurate` reads correct with all emitters present. Rationale + the current formulas live in `Correction.h`'s emitter-field comment.

### Spacer / gapped-strand layout — dark gaps between physical LED segments (user request)

**User request (via PO):** driving a slat wall on the P4 where each data pin's physical strand has BLACK GAPS between addressed segments — e.g. pin 1's strand is 550 LEDs physically, but column 1 is LEDs 0–250, column 2 is LEDs 300–550, and 251–299 are unaddressed spacer LEDs that must stay dark. Today's layouts map a contiguous run of pixels per strand; there is no way to express "skip N physical LEDs here, then resume."

The need is a layout (or layout modifier) that inserts **spacer regions** into a strand's pixel-to-physical mapping: the effect/grid still sees a contiguous logical space, but the driver's per-strand output leaves the gap LEDs at zero (dark). Design questions to settle before building: is this a new **layout type** (a "SlatWall"/"SpacedStrip" that takes segment lengths + gap lengths), a **modifier** on an existing layout (insert gaps into the coordinate stream), or a **driver-level** per-strand offset map? The driver already has a window (`start`/`count`) per strand; a gap is essentially multiple windows per strand. Physics constraint whatever the design: a serial WS2812 strand cannot skip positions (LED n+1's data passes through LED n), so every spacer LED must receive explicitly clocked ZERO bytes each frame — "unmapped" coordinates still occupy wire slots, and the design must guarantee those slots carry zeros, not stale buffer content. Spec it (a `docs/moonmodules/light/layouts` entry) before code; the panels/grid layout code is the reference for how coordinates map to the buffer.

### Mixing light types in one Layouts — open design question (undesigned)

Today each layout child describes one light type (all LED strips, or all par lights), and the current model is one Layouts container per light type. Whether a single Layouts should hold mixed types (LED strips + par lights together), and how the per-channel layout would reconcile across them, isn't designed. Deferred until a concrete need forces it; it's adjacent to the fixture model above (a real fixture/attribute model may reframe how mixed types are expressed). (Moved from architecture.md § What we leave undesigned; a deferred design decision, not a settled 🚧 one.)
Expand All @@ -211,6 +205,13 @@ Today each layout child describes one light type (all LED strips, or all par lig

The preview's transport — resumable cross-tick send from a stable buffer + newest-wins backpressure drop + adaptive graceful degradation (see [architecture.md § graceful degradation under transport backpressure](../architecture.md)) — is **payload-agnostic**: any bulky throttled stream (a future MJPEG/video preview, fixture-state streams, fleet telemetry) could ride it. The *payload* model (count/stride/RGB) is light-specific; the *byte-pump* is not. When a second consumer for this transport appears, promote the pump into a domain-neutral core primitive (a `ThrottledChannel`-style sink) that PreviewDriver becomes *a* producer on, rather than owning the protocol. Concrete-first: extract on the second use, not before — until then the seam stays inside HttpServerModule/PreviewDriver.

### PreviewDriver `resumableFrames` default OFF: fix the tearing, then un-skip the dynamicBytes test

`resumableFrames` (the downsampled-frame transport A/B) now defaults **OFF** because the resumable path visibly **tears the preview**: it shares the single-occupancy WS send slot with the ~1 Hz full-state push and the next preview frame, so a preempted mid-drain frame reaches the browser spliced (top rows new, the rest stale — PO saw it on the wall). OFF uses the proven-correct synchronous transport. Two follow-ups, both small:

1. **The tear itself (the reason to eventually want ON again).** Give the preview send its OWN send slot instead of sharing `previewSend_` with the state push, OR make a preempted drain drop cleanly (versioned frame → the browser discards a spliced one) rather than splicing. Only then is ON safe to default. The off-thread send exists to avoid the ~17 ms render hitch at very large grids with the preview open, so this matters mainly for big walls; until fixed, synchronous is correct.
2. **The skipped test.** `unit_PreviewDriver.cpp`'s "reports its resumable-path buffers in dynamicBytes" is `doctest::skip()`'d: it was written when `resumableFrames` defaulted ON and its first assertion relied on the rig constructor's `applyState()` allocating the staging buffer via that default. With the flag OFF, toggling it ON post-construction + `prepare()` did not re-allocate the buffers in the test rig the way the constructor path did (the "ON" reads dropped to 0). The *accounting* (`driverHeapBytes` sums `stageCap_` + `keptIdxCap_`) is unchanged and correct; only the test's default assumption broke. Un-skip by rebuilding the rig so it can deterministically allocate the resumable-path buffers with the flag OFF-by-default — likely wiring the flag ON into the rig BEFORE its first `drivers.applyState()` so the same acquire path production uses runs (the post-construction toggle route resisted several attempts; the constructor-time route is the one to nail). Small test-only work.

## LCD / DMA driver work

### Drop the i80 WR/DC sacrificial pins — done for MoonI80, open for I80
Expand Down
4 changes: 2 additions & 2 deletions docs/coding-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ When a `switch (type)` outside the type's home file is legitimate: the caller ha

- **Light-domain modules and the `MoonModule` base: header-only.** Every effect, modifier, driver, layout, the light-domain containers (`Layouts`, `Layers`, `Drivers`, `Layer`), and the `MoonModule` base class live in a single `.h` with implementation inline. The benefit is concrete: a contributor copies `RainbowEffect.h`, edits, saves as `MyEffect.h`, registers one line in `main.cpp` — no "where does the `.cpp` go, what does CMake need" friction. The chain `RainbowEffect.h → EffectBase.h → MoonModule.h` is uniform; readers don't pivot to a different file shape at the base. When a light-domain file outgrows one concern, extract a helper into its own header (`BlendMap`, `MappingLUT`) rather than splitting to `.h` + `.cpp`. Header-only is a feature of the light domain.
- **Core service modules: `.h` + `.cpp`.** Core modules that bridge to the platform layer or implement substantial infrastructure (`HttpServerModule`, `FilesystemModule`, `NetworkModule`, `Scheduler`, `SystemModule`, `Control`) ship as a `.h` (interface) plus a `.cpp` (implementation). Three reasons that compound: (a) implementation changes recompile only the `.cpp`, not every TU that includes the header — incremental builds are 2–5× faster on the kind of edits that happen in development; (b) readers want the interface separately from the body; (c) symbol bloat and link-time stay bounded. Small core utilities that are *almost entirely declarations or inline accessors* — `types.h`, `color.h`, `version.h`, `BinaryBroadcaster.h`, `JsonUtil.h`, `JsonSink.h`, `Sha1.h`, `Base64.h` — stay header-only. Templates (e.g. `ModuleFactory::registerType<T>`) also must stay in the header because of C++ instantiation rules; a module that's mostly template can therefore stay header-only.
- **A catalog module includes ONLY its umbrella.** Every effect, modifier, layout, and concrete driver leads with exactly one include — `light/effects/Effect.h`, `light/layouts/Layout.h`, `light/modifiers/Modifier.h`, or `light/drivers/Driver.h` — and nothing else at the top of the file. The umbrella is the module author's **standard library**: it pulls in the base class, the render context, the common domain helpers (`draw` / `Palette` / `math8` / `noise` / `color` / `crc` for effects; `DriverBase` for drivers; the base + integer trig for modifiers/layouts), the lifecycle primitives (`ScratchBuffer`), the audio source, AND the standard-library headers the bodies use (`<cstring>`, `<cmath>`, `<cstdint>`, `<array>`; drivers add `<cstdio>`, `<algorithm>`). Bundling this whole surface is **byte-free** — unused declarations emit no code (measured: the ESP32 image did not grow when the umbrellas were maximised), so the reflex is *add the common header to the umbrella, don't scatter it per file*. That keeps every module in a domain reading identically and the copy-edit-register workflow free of include guesswork; it's also the surface a scripted MoonLive module gets uniformly. This is a *maximal* (prelude-style) umbrella on purpose — a recognisable pattern (Rust's `std::prelude`, a project-wide `framework.h`), justified at the introduction site in each umbrella's header comment.
- **A catalog module includes ONLY its base header.** Every effect, modifier, layout, and concrete driver leads with exactly one include — `light/effects/EffectBase.h`, `light/layouts/LayoutBase.h`, `light/modifiers/ModifierBase.h`, or `light/drivers/DriverBase.h` — the base class it subclasses, and nothing else at the top of the file. That base header is the module author's **standard library**: it declares the base class AND pulls in the render context, the common domain helpers (`draw` / `Palette` / `math8` / `noise` / `color` / `crc` for effects; `DriverBase`'s own `Layer`/`Buffer`/`Correction`/platform for drivers; the base + integer trig for modifiers/layouts), the lifecycle primitives (`ScratchBuffer`), the audio source, AND the standard-library headers the bodies use (`<cstring>`, `<cmath>`, `<cstdint>`, `<array>`; drivers add `<cstdio>`, `<algorithm>`). Bundling this whole surface is **byte-free** — unused declarations emit no code (measured: the ESP32 image did not grow when the set was maximised), so the reflex is *add the common header to the base, don't scatter it per file*. That keeps every module in a domain reading identically and the copy-edit-register workflow free of include guesswork; it's also the surface a scripted MoonLive module gets uniformly. This is a *maximal* (prelude-style) bundle on purpose — a recognisable pattern (Rust's `std::prelude`, a project-wide `framework.h`), justified at the introduction site in each base header's comment.

**The only permitted second include** is a helper that is genuinely *outside* the domain's standard surface — a network packet format (`ArtNetPacket.h`), a font table (`fonts.h`), the module factory, a platform primitive (`platform.h`), a specialised core service (`JsonUtil.h`, `DevicesModule.h`) — and it carries a one-line justification comment so the exception is visibly deliberate. If the "extra" is a *common* header two-plus modules of the same kind reach for, it is not an exception: **move it into the umbrella instead** (that is how `<cstdint>`/`<array>`/`<algorithm>` got there). Two structural notes: the umbrella includes the *kind's* real common set, not a blanket one (a driver umbrella is mostly `DriverBase`, which already bundles `Layer`/`Buffer`/`Correction`/platform); and it can't be the base class itself — `EffectBase.h` forward-declares `Layer` (whose accessors are defined in `Layer.h`), so folding `Layer.h` into the base would be circular. The umbrella is the *separate* thin header that depends on both.
**The only permitted second include** is a helper that is genuinely *outside* the domain's standard surface — a network packet format (`ArtNetPacket.h`), a font table (`fonts.h`), the module factory, a platform primitive (`platform.h`), a specialised core service (`JsonUtil.h`, `DevicesModule.h`) — and it carries a one-line justification comment so the exception is visibly deliberate. If the "extra" is a *common* header two-plus modules of the same kind reach for, it is not an exception: **move it into the base header instead** (that is how `<cstdint>`/`<array>`/`<algorithm>` got there). One structural subtlety, in `EffectBase.h` only: `Layer.h` (which defines EffectBase's out-of-line accessor bodies) includes `EffectBase.h` back, so it can't sit at the top — `EffectBase.h` forward-declares `Layer`, declares its accessors, and pulls `Layer.h` + the helper set at the **bottom of the file, after the class**, where `Layer.h` re-enters as a no-op (include guard) with EffectBase already complete. The standard forward-declare-then-include-the-definer pattern; the other three domains have no cycle and include their base + helpers top-down.
- **Exceptions need a one-line comment at the top of the file naming the reason.** Without a stated reason the file is expected to follow the default for its category. When in doubt: light → header-only, core → `.h` + `.cpp`.

## Override-and-chain convention
Expand Down
Loading
Loading