diff --git a/docs/MIGRATING.md b/docs/MIGRATING.md index ca45b37f..adcb8c06 100644 --- a/docs/MIGRATING.md +++ b/docs/MIGRATING.md @@ -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: diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md index dde0ded1..3c7c6b00 100644 --- a/docs/backlog/backlog-light.md +++ b/docs/backlog/backlog-light.md @@ -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.) @@ -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 diff --git a/docs/coding-standards.md b/docs/coding-standards.md index c98489db..453579e3 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -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`) 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 (``, ``, ``, ``; drivers add ``, ``). 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 (``, ``, ``, ``; drivers add ``, ``). 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 ``/``/`` 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 ``/``/`` 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 diff --git a/docs/history/plans/Plan-20260721 - UI resync collapse fix (persist view-state) (shipped).md b/docs/history/plans/Plan-20260721 - UI resync collapse fix (persist view-state) (shipped).md new file mode 100644 index 00000000..56cb2b96 --- /dev/null +++ b/docs/history/plans/Plan-20260721 - UI resync collapse fix (persist view-state) (shipped).md @@ -0,0 +1,69 @@ +# Plan — Fix UI resync collapse by PERSISTING view-only state, not by reconciling live DOM + +## Context + +The UI's full-state resync (fired by enable/disable, add/delete/move a module, or a real schema change) +rebuilds `#main` via `renderCards()` (`main.innerHTML = ""` + rebuild), which destroys transient UI state and +causes the recurring symptoms: enable/disable reverts, the controls `
` expander collapses, sliders +feel laggy, the tab greyness double-flips. + +A first attempt reconciled the live DOM with a keyed diff (React-style). It was too fragile — it produced a +duplicate-tabs bug — and it tried to preserve state (focus/caret, mid-drag) that isn't cleanly preservable +anyway. **The correct, simpler split (per the product owner):** +- **Backend is the source of truth** for control values, module structure, and any *module control* (the type + picker's value is a module control — it lives in the backend). A rebuild reads these fresh and correct. +- **localStorage holds the small view-only state the backend knows nothing about:** the selected tab (already + persisted as `LS_TABS`) and **whether a module's "controls" expander is open/closed** (not persisted today). + +So the fix is: **persist the expander open/closed state to localStorage (like the selected tab already is), +keep the simple `renderCards()` rebuild, and let the rebuild RESTORE that state from localStorage.** No live-DOM +diffing. Simpler, less code, and more robust — it survives a page reload too, not just a resync. + +## Design + +1. **New `LS_EXPANDED` localStorage key** — a JSON object/set of module names whose controls `
` is + open. Loaded at startup next to `selectedTabs` (~line 61), same `lsRead` helper. +2. **The `
` (createCard, ~793):** + - On build, set `d.open = expandedSet.has(mod.name)` so a rebuild restores the last open/closed state. + - On `toggle`, write `mod.name` into/out of `LS_EXPANDED` (`localStorage.setItem`), mirroring exactly how a + tab click writes `LS_TABS` (~624). +3. **Leave `renderCards()` as the full rebuild.** It already reconstructs the selected tab from `LS_TABS`; it + now also reconstructs the expander from `LS_EXPANDED`. Everything else (values, structure, picker value) + comes from the backend `state`, which is correct after any resync. No reconcile machinery. +4. **Tab greyness (the product owner's earlier request), the simple way:** a disabled child's tab gets a + `tab--disabled` class, derived purely from `child.enabled` at tab build time (renderChildTabs) and in the + patch-path twin (`updateTabDot`). Also toggled INSTANTLY in `setEnabledUi` on the on/off click (beside the + card's `card--disabled`), so the tab greys immediately instead of a beat later on the server round-trip. + Idempotent. Add the `.tab--disabled` CSS (greyed title, works whether or not the tab is the active one). + +5. **Backend enabled-resync (discovered during implementation, shipped as part of the fix).** The + enable/disable revert had a deeper cause than the client rebuild: `enabled` rides only the FULL websocket + state, never the per-second value patch, yet the enabled branch of `Scheduler::setControl` never requested + a full resync (unlike add/delete), so the client's cached state kept the old value and reverted the toggle + about a second later. Fix: a new `MoonModule::notifySchemaChanged()` (fires the existing schema-changed + hook that `HttpServerModule` wires to `requestFullResync`), called from the enabled branch. Reuses the + established hook, no new mechanism. This is the C++ side; the persist/greyness work is the client side. + +## Non-goals +- No keyed-DOM reconcile. The rebuild stays; correctness comes from backend plus two localStorage keys. +- No new persisted state beyond the expander. The picker value is a backend control; focus/caret and + mid-drag are inherently transient and out of scope (a resync mid-type or mid-drag is rare and acceptable). +- Structural changes (add/delete/move) still full-rebuild, which loses an in-progress edit or mid-drag on + those infrequent events. Accepted for now. + +## Code grounding (all `src/ui/app.js` + `src/ui/style.css`) +- `LS_EXPANDED` const + load (~53–61), an `expandedSet` in memory. +- The `
` build + toggle handler (~793) — set `.open` from the set, write the set on toggle. +- `renderChildTabs` (~605) + `updateTabDot` (~594) — add the `tab--disabled` class from `child.enabled`. +- `style.css` — `.tab--disabled` rule (greyed, `:not` guard NOT needed — grey even when active, keep the + active underline). + +## Verification (PO eyes on desktop `build/macos/projectMM`, http://localhost:8080) +- Open a module's controls `
`, then enable/disable ANOTHER module (forces a full resync): the + expander STAYS open. Reload the page: it's still open (bonus from persistence). +- Disable a module: stays disabled, no revert; its tab greys once. +- Slider drag: no snap-back (a value change no longer forces a full frame — verified this session; only a + structural change does, and those are infrequent + don't touch an unrelated open expander now). +- Add/delete/reorder a module: tree updates; unrelated expanders elsewhere stay as the localStorage set says. +- Tabs render correctly (exactly the server's children — NO duplicates; the rebuild is authoritative). +- `ctest` green (no C++ change); JS host suite green. diff --git a/docs/history/plans/Plan-20260722 - Black pixels (dark gaps) in Layouts (shipped).md b/docs/history/plans/Plan-20260722 - Black pixels (dark gaps) in Layouts (shipped).md new file mode 100644 index 00000000..9bd8d98c --- /dev/null +++ b/docs/history/plans/Plan-20260722 - Black pixels (dark gaps) in Layouts (shipped).md @@ -0,0 +1,86 @@ +# Plan: Black pixels (dark gaps) in Layouts + +## Context + +A user drives a continuous WS2812 panel/strand where some LEDs must stay DARK mid-run: data flows THROUGH the dark LEDs to reach lit ones beyond, but those positions show no light (a sealed panel with a spacer strip, a slat wall). The existing `start`/`count` window only selects one contiguous subset; it can't punch a hole mid-grid. + +A first attempt put this in the DRIVER (a `g`/`+` gap syntax on the `ledsPerPin` control). That is being **abandoned**: it's the wrong layer (a driver text-parse), and the crippling flaw is the **preview cannot show the dark gaps** (the preview renders the light buffer, not the wire). The product owner's decision: solve it in **Layouts**, so the black positions live in the mapping and the preview shows the holes in place for free. + +**Design model (settled with the PO):** the panel is HOLED, not collapsed. A 16x16 panel with columns 10-15 dark renders the FULL 16x16 effect; the lit LEDs (columns 0-9) show exactly the visible part of the picture at its true (x,y), and the dark LEDs are the part you can't see. Collapsing to a dense 10x16 would distort the geometry (a circle would squash leftward), which is wrong. So a black pixel is **a hole at its true coordinate in the full grid**, not a removed column. + +## How the existing pipeline already supports this (traced, build on it) + +The whole light pipeline funnels through one `forEachCoord` seam, and the holed model is the EXISTING sparse mechanism (the same one WheelLayout uses), applied to a grid: + +- **`Layouts::forEachCoord`** ([Layouts.h:49](src/light/layouts/Layouts.h)) emits every physical position `(idx, x, y, z)`; `totalLightCount()` ([Layouts.h:39](src/light/layouts/Layouts.h)) = Sum of child `lightCount()` sizes the driver output buffer AND the preview. +- **`Layer` derives the physical box** as the bounding box of emitted coordinates ([Layer.h:107-115](src/light/layers/Layer.h)) and builds the LUT by folding each physical coordinate to a logical cell ([Layer.h:449-468](src/light/layers/Layer.h)); a coordinate the fold rejects is DROPPED (`if (!m->modifyLogical(pos)) return;`, [Layer.h:457](src/light/layers/Layer.h)) - it consumes a physical index but maps to no logical cell. +- **`blendMap` SCATTERS** logical to physical with `clearFirst` ([BlendMap.h:88,103](src/light/layers/BlendMap.h)): it clears the output buffer, then writes each logical light to its physical destination(s). A physical index that is no light's destination is NEVER written and stays black. No gather, no bogus read. +- **The LED driver clocks its window LINEARLY** ([ParallelLedDriver.h:899](src/light/drivers/ParallelLedDriver.h)): `src + (winStart_ + laneStart_ + sourceRow)`, every position in order, whatever color is there. It does NOT walk the LUT or skip positions - so an unmapped (black) position between mapped ones is clocked BLACK IN PLACE. Data flows through. This is exactly what a dark-gap strand needs. +- **`PreviewDriver` walks `forEachCoord`** ([PreviewDriver.h:432](src/light/drivers/PreviewDriver.h)) and emits each physical `idx`'s color; a black position reads its zero-init (black) buffer slot and draws dark AT ITS (x,y). The hole shows for free. + +**Net:** the driver buffer is already physical-sized and zero-init; the scatter already leaves un-scattered positions black; the driver already clocks all positions; the preview already renders physical positions. The ONLY missing piece is a way for a layout to mark a physical/wire position as a GAP (a real physical pixel that must stay black, carrying no logical source), and for the buffer-copy step to honor that mark. Everything else falls out. + +## The model: two kinds of pixel (the PO's framing) + +A layout is a sequence of pixel emissions, of which there are now TWO kinds (instead of one). **Both are real physical pixels the driver clocks** - the difference is only whether a color reaches them: + +- **`addPixel(x,y,z)`** - a WIRE position that maps to a logical cell. The scatter writes the cell's color here; the driver clocks it lit. +- **`addBlackPixel(x,y,z)`** - a WIRE position that is a GAP: **a physical pixel that must stay black**. The scatter writes nothing to it (it carries no logical source), so it stays black; the driver STILL clocks it (data flows through the physical LED to reach lit LEDs beyond); the preview draws it dark at (x,y). + +The earlier framing "a black pixel has no physical pixel" was wrong: it DOES have a physical pixel (a real wire slot on a continuous strand - "physical LED, forced off"), it just must not receive a color. Both kinds ADVANCE the physical/wire index. This is the existing sparse-mapping shape, with the gap declared per-emission instead of emerging from a rejecting modifier. + +### Realized as a `black` flag on the shared coordinate emission + +`forEachCoord` is the one seam; a black pixel rides ON it as a trailing `bool black` (not a side-channel list, which would be a second source of truth that drifts): + +- **[LayoutBase.h:31](src/light/layouts/LayoutBase.h):** `using CoordCallback = void(*)(void* ctx, nrOfLightsType idx, lengthType x, lengthType y, lengthType z, bool black);` + - Justification at the introduction site (common-patterns-first): the black/lit distinction is co-located with the (x,y,z) it applies to, on the seam every layout and consumer already shares. + +The signature change is mechanical across the fixed set of `CoordCallback` lambdas; each either forwards or ignores `black`: +- **[Layouts.h:62](src/light/layouts/Layouts.h)** wrapper: forward `black` through (keep `offset += layout->lightCount()`, physical/wire count, unchanged - black pixels ARE counted, they occupy wire slots). +- **[Layer.h:449](src/light/layers/Layer.h)** `onCoord` (the LUT fold): `if (black) return;` at the top - a black wire position gets no logical->physical mapping in the LUT (no cell scatters to it). It still consumed its physical index in the walk, so its wire slot exists. +- **[Layer.h:107](src/light/layers/Layer.h)** dimension lambda: ignore `black` (a black pixel still expands the physical bounding box - it occupies a real wire position at (x,y)). +- **[Layer.h:399](src/light/layers/Layer.h)** `isNaturalOrder` lambda: ignore `black` (see fast-path note). +- **[PreviewDriver.h:432](src/light/drivers/PreviewDriver.h)** and its coord-table builder (~:322): ignore `black` (the real coordinate already draws the hole; the black wire slot reads its zero-init color). + +### Where the gap becomes black: the buffer-copy step + +The PO's framing pins WHERE the black is applied: **the buffer-copy step** that copies the Layer's virtual buffer to the driver output buffer - that is `blendMap` ([BlendMap.h:54](src/light/layers/BlendMap.h)). It SCATTERS (walks logical cells, writes each to its physical destination), so a black wire slot - one no cell maps to - is simply never written. Because `blendMap` does `dst.clear()` first for the bottom layer ([BlendMap.h:88](src/light/layers/BlendMap.h)), that slot stays black. So on the common single-layer path the gap is honored FOR FREE by the existing clear; no new code in the copy. **To verify during implementation:** a black slot in a NON-bottom composited layer (a second Layer alpha/additive over the first) is not re-cleared, so confirm it either can't receive stale data or add the one guard in the copy loop (skip/zero a destination flagged as a gap). This is the "add it in the buffer-copying code" the PO called out - present only if the clear doesn't already cover the composite case. + +## Authoring surface: a black-region control on GridLayout + +GridLayout is COMPUTED (nested loops in `forEachCoord`), not literally a list of `addPixel`/`addBlackPixel` calls. Do NOT convert it to emit-based (that forks the layout model). Instead the loop DECIDES per cell whether that emission is the `addPixel` or the `addBlackPixel` kind, driven by a control - the same two-kinds model, expressed computationally. + +- **[GridLayout.h](src/light/layouts/GridLayout.h):** add a `blackColumns` text control - a per-row set of x-ranges that are black (start with a SINGLE contiguous run, e.g. `"10-15"` or a `blackStart`/`blackCount` pair; empty = none). This covers the PO's stated slat-wall example exactly. Reuse the existing range-parse idiom (the `"start-end"` range shape already parsed by the pin-range / RegionModifier code), bounded and heap-free. + - `lightCount()` (physical/wire count) stays `width*height*depth` - the wire clocks every cell including black (they are physical LEDs, forced off). + - `forEachCoord` inner x-loop: compute `bool black = xInBlackSet(x)` per cell; call `cb(ctx, idx++, x, y, z, black)`. The physical `idx` advances for black cells too. Lit cells are `addPixel`, black cells are `addBlackPixel`. + - No count-space split: the effect renders the full grid box; the fold simply produces fewer LUT entries (160) than physical/wire positions (256). The 96 black wire positions carry no LUT entry and stay dark. + +Multi-run per row (`"3-5,10-15"`) and arbitrary 2D masks are deferred as later additive changes only if a real panel needs them (subtraction beats addition; the single-run form is the minimum that solves the example). + +## Fast-path guard (the one correctness trap) + +The dense-identity fast path ([Layer.h:359,364](src/light/layers/Layer.h), `dense = driverCount == boxCount`) must NOT be taken when black pixels exist: with black cells the physical bounding box volume `boxCount` still equals `driverCount` (black cells fill grid positions), so `dense` would be TRUE and the identity mapping would map black positions to themselves (lit), defeating the feature. The fold path is required so black cells are dropped. + +Fix: gate the identity fast path on "no black pixels." Cleanest is for `Layouts` to expose `hasBlackPixels()` (or the Layer to detect it during the dimension walk), and require it false for the identity path. When black pixels exist, route to `buildFoldedLUT`, which drops the black cells. **Regression pin (load-bearing):** a grid with NO black pixels must still take the identity path, byte-identical to today (the CLAUDE.md dense-fast-path constraint). + +## Revert the abandoned driver-gap work (discrete first step) + +Remove the `g`/`+` gap syntax entirely, as its own commit/step so the diff reads as "remove abandoned approach": +- **[PinList.h](src/light/drivers/PinList.h):** remove `kMaxGapsPerOutput`, `struct GapRuns`, `parsePinSegments`, the `GapRuns* gaps` out-param on `assignCounts` (and its fill loop), and the gap prose in the header comment. `assignCounts` returns to the plain number/list/broadcast parser. +- **[ParallelLedDriver.h](src/light/drivers/ParallelLedDriver.h):** remove `laneGaps_`, the gap semantics of `laneWire_`, the gap branch of `laneRowLit`, `laneNextBoundary`'s gap logic, the `laneGapCount`/`laneRowLitForTest`/`laneWire` test accessors, the `assignCounts(..., laneGaps_)` argument, the wire-length gap accumulation, and the `if (laneGaps_[i].n) return false` in `uniformLaneCounts`. Restore `laneRowLit` to the pre-gap `row < laneCounts_` test. +- **[RmtLedDriver.h](src/light/drivers/RmtLedDriver.h) / [NetworkSendDriver.h](src/light/drivers/NetworkSendDriver.h):** confirm the `assignCounts` call sites compile after the trailing param drops. +- **Tests:** remove the gap TEST_CASEs from [unit_RmtLedDriver_pins.cpp](test/unit/light/unit_RmtLedDriver_pins.cpp) and [unit_MultiPinLedDriver.cpp](test/unit/light/unit_MultiPinLedDriver.cpp). +- **Docs:** revert the `ledsPerPin` gap paragraph in [drivers.md](docs/moonmodules/light/drivers.md) and the backlog item edit in [backlog-light.md](docs/backlog/backlog-light.md). + +## Tests (pin behavior) + +- **`unit_GridLayout_blackpixel.cpp` (new):** `lightCount()` (physical) == full grid incl. black; `forEachCoord` emits black cells with `black==true` at their true (x,y) and physical `idx` advancing monotonically over ALL cells; robustness (all-black row, black-only grid, black range beyond width clamps, empty range byte-identical to no-black, 0x0x0); multi-child stitching (a black-bearing grid then a plain grid - the plain grid's physical indices start after the first grid's FULL physical count). +- **`unit_Layer_blackpixel_lut.cpp` (new):** with black pixels, `rebuildLUT` does NOT take the identity path (`lut().hasLUT()` true) and no LUT destination equals a black physical index; lit cells map to the correct physical position. **Regression:** a no-black grid still takes the identity path (`!lut().hasLUT()`), byte-identical. +- **`scenario_GridLayout_blackpixel.json` (new):** mirror `scenario_GridLayout_resize.json`; build Layouts to Layer(SolidEffect fill) to Drivers on a 16x16 grid, set the black range live (no reboot), assert the output buffer is black at black physical indices and the fill color at lit ones; clear the range back and confirm return to the dense identity path (liveness both directions). SolidEffect is the ideal probe - every lit pixel is a known non-black color, so a black slot is unambiguously a black pixel. + +## Verification + +1. **Desktop:** `cmake --build build --target mm_tests` then `ctest`; run `uv run moondeck/scenario/run_scenario.py`. New tests green; the no-black regression tests confirm the identity path unchanged. Full suite green (the `CoordCallback` signature change compiled through every lambda with no drift on existing scenarios). +2. **Spec-check + platform-boundary:** green (all `src/light/`, no platform code). +3. **On the S3 bench (192.168.1.158):** flash; add GridLayout + Layer(**SolidEffect**, solid red) + a driver; set the black range live and confirm (a) lit LEDs light red, (b) black-region LEDs stay dark on the wire while data reaches the lit LEDs beyond, (c) the **web preview shows the holes in place** at the black coordinates, (d) status/summary shows physical count > lit destinations. Toggle the range back to empty and confirm the whole strand lights (live, no reboot) - the identity fast path re-engages. Confirm FPS/heap unchanged for the no-black config. **Invite the PO to look; their eyes on the panel + preview are the measurement.** diff --git a/docs/history/plans/Plan-20260722 - Mid-strand dark gaps (driver feature) (attempted, abandoned).md b/docs/history/plans/Plan-20260722 - Mid-strand dark gaps (driver feature) (attempted, abandoned).md new file mode 100644 index 00000000..7b2b2d1a --- /dev/null +++ b/docs/history/plans/Plan-20260722 - Mid-strand dark gaps (driver feature) (attempted, abandoned).md @@ -0,0 +1,67 @@ +# Plan: Mid-strand dark gaps — a shared driver feature + +## Context + +A user needs to drive a continuous LED strand that has **dark segments between lit ones** (e.g. one data pin: 250 lit, 50 dark, 250 lit) where the strand is fixed/sealed and the WS2812 data must flow THROUGH the dark LEDs to reach the lit ones beyond them. Cutting the LEDs isn't an option (a commercial/potted fixture), and the existing `start`/`count` window can only select ONE contiguous subset per driver — not per-pin dark runs mid-strand. + +This is a **general** capability (any fixed continuous strand with mid-run dark LEDs — signage, slat walls, repurposed panels), not one user's wiring. The backlog item `docs/backlog/backlog-light.md § "Mid-strand dark gaps (slat wall)"` already settled the analysis: it is a DRIVER feature (a layout emits only real lights; the gap lives at the driver's wire-position→light-index seam), and the only case needing code is the continuous-strand-through-the-gap one. + +**Original user request (cross-checked):** the user asked for a *"spacer layout"* on their **P4** slat wall — "Pin 1 drives Column 1 0-250 leds and column 2 is 300-550 with 251-299 remaining black." That is EXACTLY the single-pin continuous-strand-through-the-gap case: one pin, 550 wire positions, lit 0-250 + dark 251-299 + lit 300-550 → our `ledsPerPin = "250+49g+251"` (or similar). Two consequences: (a) the P4 uses `ParlioLedDriver`, which IS `ParallelLedDriver` (CRTP) and shares `encodeRows`/`prefillShiftRows` — so the ParallelLedDriver scope covers the user's actual hardware directly; (b) the user's mental model is a *layout* they pick, but we deliver a *driver control* (`ledsPerPin` gap) — a discoverability gap the docs + the user-facing reply must close (tell them: not a layout, it's the `ledsPerPin` gap syntax on the Parlio driver). + +The insight from design discussion: the gap concept is NOT lane-specific. `assignCounts` (`src/light/drivers/PinList.h`) is the SHARED distribution primitive that ParallelLedDriver, RmtLedDriver, and NetworkSendDriver (`lightsPerIp`) all use identically — each output owns a contiguous source slice `[offset, offset+counts[i])`. So the gap belongs in that one shared primitive, and every driver inherits it (*Complexity lives in core*). + +## Design (settled with the user) + +- **Syntax:** in `ledsPerPin` / `lightsPerIp`, a `g`-suffixed run is a gap (dark, addresses no source light). Within-output delimiter `+`, between-output `,`. Example: `"250+50g+250, 300"` = output0 is lit-250 / dark-50 / lit-250, output1 is a plain 300. A gap-free spec is fully backward-compatible (one lit segment). +- **Gap semantics = black/zero on the wire:** LED drivers emit the existing idle-LOW/dark word for gap positions (data still clocks through); NetworkSendDriver packs zero (black) for gap channels, so the receiver's addressing stays contiguous. +- **Hot-path guarantee (load-bearing):** gap resolution happens in `prepare()` (cold path), folding into FLAT precomputed per-lane/per-output arrays — NEVER a per-light segment-list walk in the render loop. The gap-free case (the overwhelming majority) must be PROVABLY unchanged, not just "cheap": the whole gap logic is gated behind `if (laneGapN_[lane])` (0 for a gap-free lane), so a gap-free lane runs one always-not-taken branch (predicted, ~0) and `srcRow == row` exactly as today. Only a lane that actually declares gaps pays the ≤`kMaxGapsPerOutput` bounded resolution. Enforced by: (a) a unit test asserting a gap-free config produces a BYTE-IDENTICAL frame to before, and (b) a gap-free encode-µs A/B on the wall (verification #1) — any measurable regression on the gap-free path is a bug to fix, not accept. + +## Findings from exploration (what's shared vs per-driver) + +`assignCounts` (`src/light/drivers/PinList.h:61`) is the shared distribution primitive used by FOUR drivers (ParallelLedDriver — the CRTP base for MoonI80/Parlio/MultiPin — plus RmtLedDriver and NetworkSendDriver). All accumulate a contiguous offset so output *i* owns `[offset, offset+counts[i])`. But only the **parse/distribution** is shared; each driver's **gather** differs and none has any concept of "a wire position that emits black but consumes no source light": + +- **ParallelLedDriver** (`encodeRows`, ~line 840-853): per-lane row-major. Source index = `winStart_ + laneStart_[lane] + row`; the existing short-strand skip `if (row >= laneCounts_[lane]) continue;` (line 845) ALREADY emits an idle-LOW/dark word (mask bit unset + zeroed `wire`). A mid-strand gap is that same mechanism at a different row range. `prefillShiftRows` (line 767-787) also reads `laneCounts_` per run and must treat gap edges as run boundaries. Per-lane state is fixed member arrays `laneCounts_[kMaxStrands]`/`laneStart_[kMaxStrands]` (line 1006-7); populated in `parseConfig` (line 1391-1409, cold path). `maxLaneLights_` (line 1408) must be fed the gap-INFLATED wire length (lit+gap). +- **RmtLedDriver** (~line 250-279): flat encode, source-index==symbol-index locked; the `pinStart = pinOffset/wordsPerLight` fast-path (line 272) breaks once gaps decouple the two. (Deferred — see Scope.) +- **NetworkSendDriver** (~line 283-322): byte-cursor packing straight from `data`. Gap = pack zero for gap channels. (Deferred — see Scope.) + +Key inversion: today every advanced source light IS emitted; a gap advances the WIRE cursor while NOT advancing the source cursor. That's the one new idea each gather must learn. + +Existing tests: `assignCounts` is tested via `test/unit/light/unit_RmtLedDriver_pins.cpp` (the `--- assignCounts ---` section, ~line 134-259) — no dedicated PinList test file. ParallelLedDriver has `unit_ParallelLedDriver_*` / `unit_MultiPinLedDriver.cpp`. + +## Scope (this pass) — decided with the user + +Concrete-first: **the shared parser gets the gap now (all drivers inherit the parsed gaps), but only ParallelLedDriver's gather is implemented** — it's the driver the backlog named, and its short-strand skip already IS the dark-emit mechanism. RMT/Network gathers are follow-ups (they'll already receive the parsed gaps; only their small "emit black" edit remains). N gaps per output are supported (a panel on one pin may gap more than once). + +## Implementation + +### 1. Shared parse layer — `src/light/drivers/PinList.h` + +`assignCounts` today fills `counts[i]` (lit lights per output). Add a parallel **gap descriptor** output so a gap-aware caller gets, per output, the bounded set of gap boundaries; a gap-free caller keeps using `counts[]` unchanged (full backward compatibility). + +- **Syntax:** a `g`-suffixed run is a gap; within-output delimiter `+`, between-output `,`. `"250+50g+250, 300"` = output0: lit-250 / dark-50 / lit-250; output1: plain 300. A bare number / list / broadcast (no `g`, no `+`) parses exactly as today. +- **Output shape (flat, bounded, no heap):** alongside `counts[i]` (= total LIT lights for output i, what downstream offset-accumulation still uses), emit a small fixed per-output structure describing the gaps as WIRE-relative boundaries — e.g. `struct GapRuns { uint8_t n; nrOfLightsType at[kMaxGapsPerOutput]; nrOfLightsType len[kMaxGapsPerOutput]; }` with a small cap (`kMaxGapsPerOutput`, e.g. 4). `at[k]` = wire row where gap k begins (in this output's local wire coordinates), `len[k]` = dark length. The parser is the ONE place `+`/`g` is understood. +- Keep the existing broadcast/even-split/list cases and the `maxPerPin` clamp working; gaps only appear when the `+`/`g` tokens are present. Add a new overload (or an optional out-param) so the 3 non-Parallel callers compile unchanged until they opt in. + +### 2. ParallelLedDriver gather — `src/light/drivers/ParallelLedDriver.h` + +- **parseConfig (cold path, ~1391-1409):** after `assignCounts`, store the per-lane gap runs into fixed member arrays (mirroring `laneCounts_`/`laneStart_`, e.g. `laneGapAt_[kMaxStrands][kMaxGapsPerOutput]`, `laneGapLen_[...]`, `laneGapN_[kMaxStrands]`). Compute each lane's **wire length** = `laneCounts_[lane] + Σ gap len`, and feed the MAX wire length into `maxLaneLights_` (so gap rows are clocked) and `frameBytes_`. `laneStart_` stays the SOURCE offset (unchanged: gaps consume no source). +- **Per-lane "is row lit, and its source index" helper (hot path):** replace the bare `row < laneCounts_[lane]` test with a small inline that, given `row` (WIRE position) and the lane's gap runs, returns {lit?, sourceIndex}. For the common no-gap lane it's identical to today (one compare). For a gapped lane it's ≤`kMaxGapsPerOutput` compares — bounded, no per-light walk. sourceIndex = `winStart_ + laneStart_[lane] + (row − gapLenBefore(row))`. +- **encodeRows (~840-853):** use the helper: a lit row sets the mask bit + gathers from sourceIndex; a gap row (or past-end) does the existing `continue` (mask bit stays clear → dark word). +- **prefillShiftRows (~767-787):** the run-boundary loop already jumps `row = runEnd`; extend `runEnd` to also stop at the nearest gap edge across lanes, and the per-lane active test to the same helper, so gap rows lay down a mask-clear (dark) constant. This is the one spot that's more than a drop-in of the short-strand skip (gaps add mid-lane run boundaries the current `runEnd` doesn't model). + +### 3. Tests + +- **Parser** (extend `unit_RmtLedDriver_pins.cpp` `--- assignCounts ---`, or a new `unit_PinList.cpp`): gap token parse (`250+50g+250`), multiple gaps (`100+20g+100+20g+100`), `+`/`,` interplay, backward-compat (a plain list/broadcast yields zero gaps), bounds (over `kMaxGapsPerOutput`), malformed (`50g` alone, trailing `+`, bad tokens). +- **ParallelLedDriver gather** (extend `unit_ParallelLedDriver_*`): a lit/gap/lit lane produces the right wire pattern — the gap rows emit the dark word (mask bit clear), the post-gap lit rows read the CORRECT source index (source not advanced across the gap), and the frame is sized to the inflated wire length. A multi-gap lane. A gap-free config is byte-identical to before (regression pin). + +### 4. Docs + backlog + +- Document the `g`/`+` syntax on the `ledsPerPin` control (its `///` + the drivers doc card), **explicitly framed for the "spacer" mental model**: the user expects a layout; the docs must say "a mid-strand dark gap is a driver control, not a layout — set `ledsPerPin = 250+50g+250`," so a user searching for a spacer finds it. +- Update `docs/backlog/backlog-light.md § "Mid-strand dark gaps"`: mark the ParallelLedDriver reference SHIPPED (covers the P4/Parlio slat-wall case directly), keep a slim follow-up for the RMT + Network gathers (they inherit the parsed gaps; only their emit-black edit remains). + +## Verification + +1. **No hot-path regression (load-bearing):** build + flash the wall/bench; A/B a GAP-FREE config's encode µs (48×256, `lt=0` clean) before vs after — must be unchanged. The KPI tick timing is the guard. +2. **Unit:** `cmake --build build --target mm_tests && ./build/test/mm_tests -tc="*PinList*,*assignCounts*,*ParallelLedDriver*"` all green; full suite green. +3. **On hardware:** flash a board, set `ledsPerPin = "N+Mg+N"` on a real strand, confirm the middle M LEDs stay dark while the strand continues lit past them (data flows through), and the source lights map correctly (the effect isn't shifted). Invite the product owner to look — LEDs on a bench are the measurement. +4. Spec check + platform-boundary + scenario gates green (no platform code outside `src/platform/`; this is all `src/light/`). diff --git a/docs/moonmodules/core/services.md b/docs/moonmodules/core/services.md index 98b502b9..3d7d8315 100644 --- a/docs/moonmodules/core/services.md +++ b/docs/moonmodules/core/services.md @@ -14,17 +14,19 @@ Detail: [technical](moxygen/Services.md) ### Audio -A Service (added by the user, not auto-wired): an I²S microphone (or line-in ADC) feeding the FFT that audio-reactive effects consume via `AudioService::latestFrame()`. It also syncs audio over UDP, WLED-compatible: broadcast the local analysis for the WLED ecosystem, or receive a peer's audio to drive effects with no local mic. Idle until real GPIOs are entered. +A Service (added by the user, not auto-wired): the audio source that feeds the FFT audio-reactive effects consume via `AudioService::latestFrame()`. `mode` is the first choice, the module's identity, and each mode shows only its own detail controls: Local audio runs its own peripheral (an I²S microphone or line-in ADC) and analyzes it locally, Receive network is a pure network sink a peer's WLED-compatible audio drives, and Simulate is a synthesized source for demos and tests. Idle until real GPIOs are entered in Local mode. The Receive network mode and every network-sync control (`send audio`, `syncPort`, `sync status`) exist only on network-capable targets (`platform::hasNetwork`); a no-network build offers just Local audio and Simulate, without a mode picker if those are the only two. Audio module controls -- `sckPin` / `wsPin` / `sdPin` — the I²S GPIOs (bit clock / word-select / data; unset until entered). -- `mclkPin` — master-clock GPIO for a line-in ADC that needs one (e.g. the PCM1808); leave unset for a plain mic. -- `sampleRate` — mic/ADC sample rate. -- `floor` / `gain` — noise floor and input gain for the analysis. -- `simulate` — feed a synthetic signal instead of the mic (for testing without hardware). -- `sync` — Off / Send / Receive: broadcast or receive WLED audio-sync packets. `syncPort` sets the UDP port (default 11988, the WLED standard); Receive auto-blends back to the local mic ~1 s after a peer goes quiet. -- read-only — `level` (RMS), `peakHz`, `sync status`. +- `mode` — Local audio / Receive network / Simulate: analyze the on-board mic/line-in, consume a peer's audio off the network (WLED-compatible), or feed a synthesized signal. Receive network appears only on a network build; the controls below are its detail, shown per mode. +- `sckPin` / `wsPin` / `sdPin` — (Local) the I²S GPIOs (bit clock / word-select / data; unset until entered). +- `mclkPin` — (Local) master-clock GPIO for a line-in ADC that needs one (e.g. the PCM1808); leave unset for a plain mic. +- `sampleRate` — (Local) mic/ADC sample rate. +- `floor` / `gain` — (Local) noise floor and input gain for the analysis. +- `send audio` — (Local, network build) broadcast the local analysis as WLED audio-sync packets for the WLED ecosystem. +- `simulate` — (Simulate) the synthetic pattern: `music` (a plausible song) or `sweep` (a deterministic band-marching test pattern). +- `syncPort` — (network build) the UDP port (default 11988, the WLED standard), shown when sending or receiving; set it the same on both ends. `sync status` reports the live send/receive state. +- read-only — `level` (RMS), `peakHz` (the audio driving effects, from any source). Detail: [technical](moxygen/AudioService.md) diff --git a/docs/moonmodules/light/drivers.md b/docs/moonmodules/light/drivers.md index a1ede71e..4a537875 100644 --- a/docs/moonmodules/light/drivers.md +++ b/docs/moonmodules/light/drivers.md @@ -37,7 +37,7 @@ Addressable WS2812B-class LEDs over a wire. Four drivers, same controls and same Plus the [shared controls](#shared-driver-controls) above: -- `pins` — data GPIO list, e.g. `18,17,16`. One strand each — or, with Moon's pin expander, one *group of 8*. Empty idles until set; changing it re-inits live. +- `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. - `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). diff --git a/docs/moonmodules/light/layouts.md b/docs/moonmodules/light/layouts.md index da85ec5e..d429cddc 100644 --- a/docs/moonmodules/light/layouts.md +++ b/docs/moonmodules/light/layouts.md @@ -194,6 +194,23 @@ Detail: [technical](moxygen/GridLayout.md) [Tests](../../tests/unit-tests.md#gridlayout) + + +### GridBlacks + +A [Grid](#grid) with **mid-strand dark columns (a spacer)**. Columns `[blackStart, blackStart+blackCount)` are held black in every row, for a sealed/continuous panel that must stay dark down a strip, or a slat wall. A dark column is still a physical wire position the driver clocks, so WS2812 data flows *through* the unlit LEDs to reach the lit columns beyond; the lit columns keep their true positions (the picture is holed, not squeezed), so an effect maps straight across the gap. Use plain [Grid](#grid) when you need no dark columns. + +- `width` / `height` / `depth` — grid extent on each axis in lights (1–512). +- `serpentine` — boustrophedon-wire alternate rows. +- `blackCount` — number of dark columns; `0` (the default) means no gap, so it renders exactly like a Grid. +- `blackStart` — first dark column (shown only once `blackCount` is set). + +Origin: projectMM + +Detail: [technical](moxygen/GridBlacksLayout.md) + +[Tests](../../tests/unit-tests.md#gridblackslayout) + ### Sphere diff --git a/docs/usecases/build-your-own-moonmodules.md b/docs/usecases/build-your-own-moonmodules.md index 0211f1a1..81b89577 100644 --- a/docs/usecases/build-your-own-moonmodules.md +++ b/docs/usecases/build-your-own-moonmodules.md @@ -65,7 +65,7 @@ Here is a complete, real effect — a diagonal rainbow that scrolls over time. R ```cpp #pragma once -#include "light/effects/Effect.h" // one include: the base + draw / palette / maths helpers +#include "light/effects/EffectBase.h" // one include: the base + draw / palette / maths helpers namespace mm { @@ -109,7 +109,7 @@ That's the *entire* effect — just `defineControls()` and `tick()`. A simple ef ### One include gets you everything -`#include "light/effects/Effect.h"` is the umbrella header for effects: it pulls in the base class, the render-context accessors (`buffer()`, `width()`, …), and the common helpers (`draw::*`, the palette system, the `math8` animation helpers, `RGB`). Include that one file and start writing. The other kinds have the same convention — `light/layouts/Layout.h`, `light/modifiers/Modifier.h`, `light/drivers/Driver.h`. If you reach for a helper it doesn't bundle (audio input, say), add that one extra `#include`; nothing forces the set. +`#include "light/effects/EffectBase.h"` is the base header you subclass, and it bundles everything an effect needs: the render-context accessors (`buffer()`, `width()`, …) and the common helpers (`draw::*`, the palette system, the `math8` animation helpers, `RGB`). Include that one file and start writing. The other kinds follow the same convention — `light/layouts/LayoutBase.h`, `light/modifiers/ModifierBase.h`, `light/drivers/DriverBase.h`. If you reach for a helper it doesn't bundle (audio input, say), add that one extra `#include`; nothing forces the set. ### Reading the grid every frame diff --git a/esp32/sdkconfig.defaults b/esp32/sdkconfig.defaults index 01773e7c..cb2fabe4 100644 --- a/esp32/sdkconfig.defaults +++ b/esp32/sdkconfig.defaults @@ -1,5 +1,11 @@ # Common defaults -CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +# Main-task stack. The HTTP server runs on this task, and its deepest path stacks a 2 KB request buffer +# (handleConnection) UNDER a recursive module-tree JSON serialize (writeModuleJson recurses per tree level) +# UNDER a ~1 KB stack JsonSink — a UI refresh fires a page-load burst that drives this path repeatedly. +# 8 KB overflowed it on the classic ESP32 (smallest stack of the targets) with a "stack overflow in task +# main" panic-reboot on refresh. 12 KB gives the serialize path comfortable headroom; the classic has ~170 +# KB free RAM, so the extra 4 KB is cheap, and the S3/P4 have even more. +CONFIG_ESP_MAIN_TASK_STACK_SIZE=12288 # CPU at the chip's rated maximum (IDF defaults to 160 MHz) — the render loop and the ring ISR's # encode deadline are compute-bound, and the P4 ignores this symbol (its own 360 MHz default applies). @@ -44,6 +50,18 @@ CONFIG_LWIP_TCPIP_CORE_LOCKING=y # — avoids partial sends that would drop and reconnect the preview connection. CONFIG_LWIP_TCP_SND_BUF_DEFAULT=11520 +# A browser page-load opens the HTML + several JS/CSS files + the persistent WebSocket all at once, and +# every refresh churns that whole set. The default 10-socket ceiling can't hold one burst (6 files + WS + +# the listen socket + WiFi/mDNS internals) once a few closed sockets sit in TIME_WAIT, so lwIP RSTs the +# excess connections BEFORE the app's accept() ever sees them — the "load it a few times before the UI +# shows / the socket connects" symptom, with no server-side log because the app never got the connection. +# Raise the ceiling to lwIP's max (16) so a full burst fits, and cut TCP_MSL from 60 s to 5 s so a closed +# socket frees its slot in seconds instead of a minute — TIME_WAIT from rapid refreshes no longer piles up +# to the ceiling. (5 s is ample on a trusted LAN; the 2×MSL wait only guards against delayed duplicate +# segments, which a LAN doesn't produce.) +CONFIG_LWIP_MAX_SOCKETS=16 +CONFIG_LWIP_TCP_MSL=5000 + # WiFi CONFIG_ESP_WIFI_ENABLED=y diff --git a/moondeck/MoonDeck.md b/moondeck/MoonDeck.md index 93e5a33c..645d05f5 100644 --- a/moondeck/MoonDeck.md +++ b/moondeck/MoonDeck.md @@ -23,13 +23,23 @@ Below: the UI behaviours common to every card, described once, then one section ### build_desktop -Build the desktop target using CMake. +Build the desktop firmware binary using CMake. ```bash uv run moondeck/build/build_desktop.py ``` -Runs `cmake -B build/ -DCMAKE_BUILD_TYPE=Release` then `cmake --build build/`, where `` is `macos`, `linux`, or `windows` depending on the OS this script runs on. The per-host directory keeps an experimental Linux build from clobbering a macOS one on the same machine, and mirrors the ESP32 side's `build/esp32-/` shape. +Runs `cmake -B build/ -DCMAKE_BUILD_TYPE=Release` then `cmake --build build/ --target projectMM`, where `` is `macos`, `linux`, or `windows` depending on the OS this script runs on. It builds ONLY the firmware binary — not the ~130-file test suite — so the "just give me the binary to run" path stays fast; compile the tests separately (see `compile_tests`). The per-host directory keeps an experimental Linux build from clobbering a macOS one on the same machine, and mirrors the ESP32 side's `build/esp32-/` shape. + +### compile_tests + +Compile the test binaries (unit + scenario) without running them. + +```bash +uv run moondeck/build/build_desktop.py --tests +``` + +Builds `mm_tests` + `mm_scenarios` (the `--tests` target set of `build_desktop.py`), in the same per-host build dir. Separate from `build_desktop` so a firmware build doesn't drag the ~130 test translation units through the compiler; run the compiled binaries afterward with `test_desktop` (unit) and `scenario_pipeline` (scenarios). ### test_desktop diff --git a/moondeck/build/build_desktop.py b/moondeck/build/build_desktop.py index 319b8497..4755d842 100644 --- a/moondeck/build/build_desktop.py +++ b/moondeck/build/build_desktop.py @@ -5,6 +5,11 @@ on Windows — so a single machine could (in principle) build multiple host flavours without one wiping the other, and so the layout matches the ESP32 side (``build/esp32-/``, one dir per target). + +Builds ONE target, not the whole project: the firmware binary (``projectMM``) by +default, or the test binaries (``--tests`` → ``mm_tests`` + ``mm_scenarios``). +Keeping them separate means the "just give me the binary" build doesn't wait on +the ~130-file test suite (and vice-versa). """ import argparse @@ -52,6 +57,10 @@ def main(): ap.add_argument("--gcc", action="store_true", help="build with GCC instead of the default compiler — the toolchain CI uses. " "Catches the warnings clang does not emit.") + ap.add_argument("--tests", action="store_true", + help="compile the test binaries (mm_tests + mm_scenarios) instead of the firmware. " + "The default build makes only projectMM; the ~130 test units are a separate, " + "slower compile, run afterwards by test_desktop.py / run_scenario.py.") args = ap.parse_args() bdir = host_build_dir() @@ -67,7 +76,8 @@ def main(): build_type = "Debug" extra = [f"-DCMAKE_C_COMPILER={cc}", f"-DCMAKE_CXX_COMPILER={cxx}"] print(f"Using GCC ({cxx}) in Debug — the exact toolchain + build type CI uses.") - print(f"Building desktop target into {bdir}/ ...") + what = "test binaries" if args.tests else "desktop target" + print(f"Building {what} into {bdir}/ ...") # CMAKE_BUILD_TYPE is honoured by single-config generators (Ninja, Make). # Visual Studio is multi-config and ignores it — we pass --config Release at # build time below. Setting CMAKE_BUILD_TYPE on multi-config is harmless. @@ -78,7 +88,12 @@ def main(): if r.returncode != 0: sys.exit(r.returncode) - build_cmd = ["cmake", "--build", bdir] + # Build a SPECIFIC target, never the whole "all" — the firmware and the ~130 test translation units are + # separate concerns. Default builds only projectMM (the "just give me the desktop binary" path stays + # fast; a header edit no longer drags the test suite through the compiler). `--tests` builds the test + # binaries instead, which test_desktop.py runs (mm_tests) and run_scenario.py runs (mm_scenarios). + targets = ["mm_tests", "mm_scenarios"] if args.tests else ["projectMM"] + build_cmd = ["cmake", "--build", bdir, "--target", *targets] if is_windows: build_cmd += ["--config", "Release"] r = subprocess.run(build_cmd, cwd=ROOT) diff --git a/moondeck/moondeck_config.json b/moondeck/moondeck_config.json index 33ffb545..35de98cd 100644 --- a/moondeck/moondeck_config.json +++ b/moondeck/moondeck_config.json @@ -8,6 +8,15 @@ "help": "build_desktop", "script": "build/build_desktop.py" }, + { + "id": "compile_tests", + "tab": "desktop", + "group": "test", + "label": "Compile Tests", + "help": "compile_tests", + "script": "build/build_desktop.py", + "args": ["--tests"] + }, { "id": "test_desktop", "tab": "desktop", diff --git a/moondeck/scenario/run_scenario.py b/moondeck/scenario/run_scenario.py index 8400665e..e33734c4 100644 --- a/moondeck/scenario/run_scenario.py +++ b/moondeck/scenario/run_scenario.py @@ -212,7 +212,7 @@ def main(): if not RUNNER.exists(): print(f"Scenario runner not found: {RUNNER}") - print("Run build_desktop.py first.") + print("Compile it first: uv run moondeck/build/build_desktop.py --tests (MoonDeck → desktop → Compile Tests).") sys.exit(1) module_filter = args.module if (args.module and args.module.lower() != "all") else None diff --git a/moondeck/test/test_desktop.py b/moondeck/test/test_desktop.py index 5647710b..6f9baec8 100644 --- a/moondeck/test/test_desktop.py +++ b/moondeck/test/test_desktop.py @@ -52,7 +52,7 @@ def main(): if test_exe is None: print(f"Test executable not found under " f"{build_dir.relative_to(ROOT)}/test/.") - print("Run build_desktop.py first.") + print("Compile it first: uv run moondeck/build/build_desktop.py --tests (MoonDeck → desktop → Compile Tests).") sys.exit(1) # Default: summary-only output (`-s` dumps every passing assertion, ~11k lines diff --git a/src/core/AudioService.h b/src/core/AudioService.h index 4e823f47..30371728 100644 --- a/src/core/AudioService.h +++ b/src/core/AudioService.h @@ -128,23 +128,40 @@ class AudioService : public MoonModule { ///< ambient room dark, lower for a quiet room. uint8_t gain = 222; ///< sensitivity — HIGHER = more (a narrower dB window ///< so a given sound fills more of the bar). - /// Simulated audio: fill the frame with a synthesized signal so audio-reactive effects are demoable - /// (and testable) without a mic or music, such as a preview/demo device with no microphone. Two patterns: + /// Simulated-audio pattern (only shown, and only used, in Simulate mode — see `mode`). The synthesized + /// signal drives audio-reactive effects with no mic or music, for a preview/demo device or a test: /// `music` — a plausible song: multi-sine bands + a swelling volume + a periodic beat + a /// sweeping peak. Nice for demos (bars dance, VU breathes, peaks move). /// `sweep` — a single band lit, marching bass→treble on a timer, with the peak frequency and a /// steady volume tracking it. Deterministic — the clean test pattern to check that each /// effect responds across the whole spectrum. - /// A real mic always wins: when `mode` is a fill-in ("silence") mode it only runs while there's no real signal. - uint8_t simulate = 0; ///< 0 = off, 1 = music (fill silence), 2 = sweep (fill silence), - ///< 3 = music (always), 4 = sweep (always) - /// WLED audio-sync over UDP (port 11988, broadcast, WLED v2 wire format — - /// light/WLEDAudioSyncPacket.h). 0 = Off (local audio only, no socket bound); - /// 1 = Send (broadcast this device's AudioFrame for WLED/MoonLight receivers); - /// 2 = Receive (bind 11988; a peer's audio overwrites frame_ so effects react to - /// it, auto-falling-back to the local mic when packets stop for ~1 s). The socket - /// is bound only in Send/Receive — Off costs nothing. Changing it re-binds live. - uint8_t sync = 0; ///< 0 = off, 1 = send, 2 = receive + uint8_t simulate = 0; ///< 0 = music, 1 = sweep (the pattern; Simulate mode only) + /// The module's audio SOURCE, the first thing to pick (below status). Three either/or modes, each with + /// its own detail controls (the others hide): 0 = Local audio (its own peripheral — the on-board mic / + /// line-in; analyze it here and optionally broadcast it, so pins, rate, floor/gain and "send audio" + /// show). 1 = Receive network (a pure network sink: bind the sync port and let a peer's AudioFrame drive + /// the effects — no local peripheral). 2 = Simulate (a synthesized source for demos/tests — only the + /// `simulate` pattern picker shows). A device is exactly one of these at a time. Changing it re-runs + /// prepare() (acquires/releases the mic, rebinds/unbinds the socket) and re-toggles which controls + /// show, all live. + uint8_t mode = 0; ///< 0 = local audio, 1 = receive network, 2 = simulate + /// The `mode` value that means Simulate. "receive network" (index 1) only exists where the network + /// does, so Simulate is index 2 with a network stack and index 1 without — the whole mode set shifts, + /// and every mode comparison uses this constant rather than a bare literal (defineControls + tick). + static constexpr uint8_t kSimMode = platform::hasNetwork ? 2 : 1; + /// Broadcast this device's AudioFrame over UDP (WLED v2 wire format) for WLED / MoonLight + /// receivers. Only meaningful, and only shown, in Local mode (there's nothing to send when + /// you're a network sink). Off by default: a fresh module analyzes locally and broadcasts + /// nothing until you opt in. + bool send = false; + /// The three source/sink states the sync machinery keys off, derived from mode + send so the + /// socket/tick logic stays a single 0/1/2 switch (0 = no socket, 1 = broadcast, 2 = network sink): + /// Local+send → send, Local alone → off (local-only, no socket), Receive → receive, Simulate → off. + /// `send` counts ONLY in Local mode (mode 0): a persisted send=true must not broadcast in Simulate + /// mode, which has no captured frame worth sending. The `hasNetwork` guard on the Receive leg matters + /// on a no-network build: there mode 1 is Simulate (not Receive — see kSimMode), so without it a + /// Simulate device would wrongly read as "network sink" (2). + uint8_t sync() const { return (platform::hasNetwork && mode == 1) ? 2 : (mode == 0 && send ? 1 : 0); } /// The sync UDP port — the Send destination and the Receive listen port. Defaults /// to WLED's 11988 (interop with WLED/MoonLight); set it the same on both ends to /// run a private projectMM-only sync group on a non-WLED port. @@ -156,40 +173,60 @@ class AudioService : public MoonModule { ? sampleRateSel : 2]; } void defineControls() override { - controls_.addPin("sckPin", sckPin); - controls_.addPin("wsPin", wsPin); - controls_.addPin("sdPin", sdPin); - controls_.addPin("mclkPin", mclkPin); + // `mode` is the module's identity, so it's the first control (below status). Everything else is + // its detail: the local-audio group shows only in Local mode, the simulate pattern only in Simulate + // mode, the sync group only when there's a socket. Registration order follows the dependency (the + // coding-standards rule): the gate first, the controls it gates under it, mutually-exclusive groups + // sharing a gate grouped after it. The "receive network" option only exists where the network does + // (Local + Simulate always; Receive added when platform::hasNetwork), so Simulate is index 1 on a + // no-network build and 2 with network — the class constant kSimMode carries that shift. + const bool localMode = (mode == 0); + const bool simMode = (mode == kSimMode); + if constexpr (platform::hasNetwork) { + static constexpr const char* kModeOptions[] = {"local audio", "receive network", "simulate"}; + controls_.addSelect("mode", mode, kModeOptions, 3); + } else { + static constexpr const char* kModeOptions[] = {"local audio", "simulate"}; + controls_.addSelect("mode", mode, kModeOptions, 2); + } + // --- Local-audio group: the on-board mic / line-in and its analysis. Shown only in Local mode. The + // pins default UNSET (-1) so adding the module can't grab GPIOs; order follows the I2S datasheet + // (clocks, data, optional MCLK). --- + controls_.addPin("sckPin", sckPin); controls_.setHidden(controls_.count() - 1, !localMode); + controls_.addPin("wsPin", wsPin); controls_.setHidden(controls_.count() - 1, !localMode); + controls_.addPin("sdPin", sdPin); controls_.setHidden(controls_.count() - 1, !localMode); + controls_.addPin("mclkPin", mclkPin); controls_.setHidden(controls_.count() - 1, !localMode); static constexpr const char* kRateOptions[] = {"8000", "16000", "22050", "44100"}; controls_.addSelect("sampleRate", sampleRateSel, kRateOptions, kSampleRateCount); - // floor/gain condition the LOCAL FFT/level mapping. They stay visible in every sync - // mode — including Receive, because auto-blend falls back to the local mic when the - // peer goes quiet, and floor/gain govern that fallback. (They're only transiently - // dead while a peer's audio is actively driving the frame — not permanently, so - // hiding them by the sync setting would wrongly hide a control the fallback needs.) - controls_.addUint8("floor", floor, 0, 255); - controls_.addUint8("gain", gain, 1, 255); - static constexpr const char* kSimulateOptions[] = { - "off", "music (silence)", "sweep (silence)", "music (always)", "sweep (always)"}; - controls_.addSelect("simulate", simulate, kSimulateOptions, 5); - // WLED audio sync — only on builds with an IP stack (WiFi OR Ethernet: the UDP - // send/receive works over either, so an Ethernet-only board like the MHC-WLED - // shield still gets it). A no-network build hides the controls entirely. + controls_.setHidden(controls_.count() - 1, !localMode); + // floor/gain condition the local FFT/level mapping. + controls_.addUint8("floor", floor, 0, 255); controls_.setHidden(controls_.count() - 1, !localMode); + controls_.addUint8("gain", gain, 1, 255); controls_.setHidden(controls_.count() - 1, !localMode); + // "send audio": broadcast the locally-analyzed frame. Only meaningful in Local mode. + if constexpr (platform::hasNetwork) { + controls_.addBool("send audio", send); + controls_.setHidden(controls_.count() - 1, !localMode); + } + // --- Simulate group: the synthesized-pattern picker, shown only in Simulate mode. --- + static constexpr const char* kSimulateOptions[] = {"music", "sweep"}; + controls_.addSelect("simulate", simulate, kSimulateOptions, 2); + controls_.setHidden(controls_.count() - 1, !simMode); + // --- Sync group: only relevant when a socket is bound, i.e. sending (Local + send) or receiving. + // Both the port and the live status hide when there's no socket. A mode/send change re-runs this + // method (both are in affectsPrepare) so the rows toggle live. --- if constexpr (platform::hasNetwork) { - static constexpr const char* kSyncOptions[] = {"off", "send", "receive"}; - controls_.addSelect("sync", sync, kSyncOptions, 3); - // The UDP port — the Send destination and the Receive listen port. Defaults to - // WLED's 11988 (interop with WLED/MoonLight); change it on BOTH ends to run a - // private projectMM-only sync group on a non-WLED port. Shown whenever sync is on - // (Send or Receive), hidden in Off. A `sync` change re-runs this method (it's in - // affectsPrepare) so the row toggles live. + const bool hasSocket = (sync() != 0); + // The UDP port — the Send destination and the Receive listen port. Defaults to WLED's + // 11988 (interop with WLED/MoonLight); change it on BOTH ends to run a private + // projectMM-only sync group on a non-WLED port. controls_.addUint16("syncPort", syncPort, 1, 65535); - controls_.setHidden(controls_.count() - 1, sync == 0); + controls_.setHidden(controls_.count() - 1, !hasSocket); controls_.addReadOnly("sync status", syncStr_, sizeof(syncStr_)); + controls_.setHidden(controls_.count() - 1, !hasSocket); } - // Read-only live read-outs (formatted in tick1s). Derived every second, - // nothing to persist, so ReadOnly (the display-only type) not a flipped - // Text — same idiom as SystemModule's uptime/fps. + // Read-only live read-outs (formatted in tick1s). These show the audio actually driving the + // effects, so they stay visible in Receive too (there the frame comes off the network, not a + // mic). Derived every second, nothing to persist, so ReadOnly not a flipped Text. // "level RMS" = the RMS loudness; the DISPLAYED number is its peak over the 1-second window // (tick1s publishes levelPeak_, the max of the per-block RMS level, then resets it), so a // beat that lands between samples still registers. The live frame_.level the LEDs use is the @@ -200,24 +237,27 @@ class AudioService : public MoonModule { MoonModule::defineControls(); } - /// A pin or rate change rebuilds the I2S channel (live, no reboot); a `sync` / - /// `syncPort` change re-binds/unbinds the UDP socket AND re-toggles the port row's - /// visibility (all flow through prepare → rebuildControls). + /// A pin or rate change rebuilds the I2S channel (live, no reboot); a `mode` / `send` / + /// `syncPort` change re-binds/unbinds the UDP socket AND re-toggles which control rows show + /// (all flow through prepare → rebuildControls). bool affectsPrepare(const char* name) const override { return std::strcmp(name, "wsPin") == 0 || std::strcmp(name, "sdPin") == 0 || std::strcmp(name, "sckPin") == 0 || std::strcmp(name, "mclkPin") == 0 - || std::strcmp(name, "sampleRate") == 0 || std::strcmp(name, "sync") == 0 - || std::strcmp(name, "syncPort") == 0; + || std::strcmp(name, "sampleRate") == 0 || std::strcmp(name, "mode") == 0 + || std::strcmp(name, "send audio") == 0 || std::strcmp(name, "syncPort") == 0; } - /// Pure build (see MoonModule::prepare): claim the mic election (first live mic wins) and - /// (re)acquire the I²S mic + sync socket. No enabled() check — core's applyState() calls this only - /// when effectively-enabled and routes to release() otherwise, which releases the mic + sync - /// socket and vacates the election, so a disabled service (or one under a disabled parent) frees - /// its pins for another module. + /// Pure build (see MoonModule::prepare): claim the frame election (this instance's frame_ drives the + /// effects in EVERY mode — a live mic, a received peer frame, or a synthesized one), then acquire only + /// the hardware the current `mode` needs. Only Local runs a real peripheral, so only Local inits the + /// I²S mic; Receive (a network sink) and Simulate (a synthetic source) free the I²S channel and its + /// pins. The sync socket is rebound in every mode (syncReinit is a no-op unless there's a socket to + /// bind). No enabled() check — core's applyState() calls this only when effectively-enabled and routes + /// to release() otherwise. void prepare() override { - micSeat_.claim(); // first live mic wins the seat; a 2nd is captured but not read (claim-if-empty) - reinit(); + 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 syncReinit(); } /// One-time wiring only; the mic acquire + election live in prepare(), the sole gate. @@ -270,30 +310,31 @@ class AudioService : public MoonModule { // seat is held, the reclaim once it's empty. micSeat_.claim(); - // WLED audio sync. Send broadcasts the current frame_ (throttled). Receive drains - // the socket into frame_ and, while a peer's audio is fresh, RETURNS so the local - // mic analysis below is skipped (the received frame drives the effects); once the - // peer goes quiet (~1 s) it falls through and the local mic resumes (auto-blend). + // WLED audio sync. Send (Local + send audio) broadcasts the current frame_ (throttled), + // then falls through to run the local mic analysis that produces that frame_. Receive is a + // pure network sink: drain the socket into frame_ and RETURN unconditionally so the local + // mic path never runs (there is no peripheral in this mode), holding the last frame while a + // peer is quiet rather than blending to a mic. if constexpr (platform::hasNetwork) { - if (sync != 0 && syncEnsureSocket()) { // lazy-open once the network is up - if (sync == 1) syncSend(); - else if (sync == 2 && syncReceive()) return; + const uint8_t s = sync(); + if (s != 0 && syncEnsureSocket()) { // lazy-open once the network is up + if (s == 1) syncSend(); + else if (s == 2) { syncReceive(); return; } } + if (s == 2) return; // sink with no socket yet: still never runs the local mic } - // Simulated audio (see the `simulate` control): 0=off, 1=music-on-silence, 2=sweep-on-silence, - // 3=music-always, 4=sweep-always. Real mic input always wins in the "on-silence" modes — the - // mic path below resets realQuietMs_ whenever a block carries signal, and synthesizeFrame() - // no-ops while that timer says the mic is live. Off I2S (desktop / mic-less) or on a bad init - // there's no mic, so the sim is the only possible source: run it and return. - const bool simSweep = (simulate == 2 || simulate == 4); - const bool simAlways = (simulate >= 3); + // Simulate mode (see `mode`): a synthesized source, driven by the `simulate` pattern (0 = music, + // 1 = sweep). It's a whole mode, not a mic fill-in, so it always runs and returns — the mic path + // below never executes in Simulate mode. + if (mode == kSimMode) { synthesizeFrame(simulate == 1); return; } + + // From here on it's Local mode. Off I2S (desktop / mic-less) or before a good init there's no mic, + // so nothing is produced — the last frame is held (no synthetic fallback; that's Simulate mode's job). if constexpr (!platform::hasI2sMic) { - if (simulate != 0) synthesizeFrame(simSweep); // the only possible source off I2S return; } else { - if (!inited_) { if (simulate != 0) synthesizeFrame(simSweep); return; } - if (simAlways) { synthesizeFrame(simSweep); return; } // forced: skip the mic entirely + if (!inited_) return; } // Drain whatever the DMA holds this tick (non-blocking) into the free tail @@ -348,15 +389,6 @@ class AudioService : public MoonModule { // live every render tick) move with the music. The window peak is the representative reading. // Display-only — the live frame_.level the effects/LEDs use is untouched. if (frame_.level > levelPeak_) levelPeak_ = frame_.level; - - // Auto fill-in (simulate 1/2): if the mic has been quiet for a bit, synthesize so the effects - // still have something to react to. `level` this block re-arms the "real audio" grace period; - // once it lapses, the sim takes over and yields again the instant real sound returns. - if (simulate == 1 || simulate == 2) { - if (frame_.level > kSimRealThreshold) realBlocks_ = kSimRealGraceBlocks; - else if (realBlocks_ > 0) realBlocks_--; - if (realBlocks_ == 0) synthesizeFrame(simulate == 2); - } } /// Fill frame_ with a synthesized signal. sweep=false → a plausible "song" (each band its own @@ -414,7 +446,8 @@ class AudioService : public MoonModule { // no samples at all → the I2S clocks aren't running — check sckPin / wsPin. // samples, all zero → clocks fine, data line dead — check sdPin (SD/DOUT) + power. // samples, non-zero → data is flowing; clear any prior diagnosis. - const bool directMicLive = inited_ && sync != 2 + // Only Local mode has a real mic to diagnose — Receive and Simulate produce frame_ without one. + const bool directMicLive = inited_ && mode == 0 && platform::audioCodecType == platform::CodecType::None; if (directMicLive) { if (micSamples1s_ == 0) @@ -432,9 +465,10 @@ class AudioService : public MoonModule { // syncReinit/syncEnsureSocket set ("waiting for network" / "…failed"); only once // open do we report the moment-to-moment send/receive state. if constexpr (platform::hasNetwork) { - if (sync == 0) std::snprintf(syncStr_, sizeof(syncStr_), "off"); + const uint8_t s = sync(); + if (s == 0) std::snprintf(syncStr_, sizeof(syncStr_), "off"); else if (syncOpen_) { - if (sync == 1) std::snprintf(syncStr_, sizeof(syncStr_), "sending"); + if (s == 1) std::snprintf(syncStr_, sizeof(syncStr_), "sending"); else std::snprintf(syncStr_, sizeof(syncStr_), (lastSyncRecv_ != 0 && platform::millis() - lastSyncRecv_ < kSyncFallbackMs) @@ -476,12 +510,6 @@ class AudioService : public MoonModule { uint32_t micNonzero1s_ = 0; bool micStatusStale_ = false; - // Auto fill-in (simulate 1/2): treat the mic as "live" for a grace window after any block above - // the threshold, so brief gaps between beats don't flip to the sim. ~2 s of blocks (≈86/block·23ms). - static constexpr uint16_t kSimRealThreshold = 4; // a block level above this counts as real sound - static constexpr uint16_t kSimRealGraceBlocks = 86; // ~2 s at ~23 ms/block before the sim takes over - uint16_t realBlocks_ = 0; // grace countdown: >0 = mic was recently live, hold off the sim - // WLED audio sync (light/WLEDAudioSyncPacket.h). One socket, bound only in Send/Receive. platform::UdpSocket syncSock_; uint32_t lastSyncSend_ = 0; // millis of the last broadcast (send throttle) @@ -581,9 +609,10 @@ class AudioService : public MoonModule { syncOpen_ = false; lastSyncOpenFailMs_ = 0; // a mode change retries bring-up immediately (no stale backoff) lastSyncRecv_ = 0; + const uint8_t s = sync(); std::snprintf(syncStr_, sizeof(syncStr_), - sync == 1 ? "send: waiting for network" - : sync == 2 ? "receive: waiting for network" + s == 1 ? "send: waiting for network" + : s == 2 ? "receive: waiting for network" : "off"); } @@ -595,7 +624,8 @@ class AudioService : public MoonModule { /// past boot so a boot-present AudioService can't touch lwip before it exists. bool syncEnsureSocket() { if constexpr (!platform::hasNetwork) return false; - if (sync == 0) return false; + const uint8_t s = sync(); + if (s == 0) return false; if (syncOpen_) return true; if (!platform::networkReady()) return false; // interface not up yet — try again next tick // Back off between failed bring-ups: tick() runs every tick, so without this a @@ -604,7 +634,7 @@ class AudioService : public MoonModule { // failure; hold off until kSyncOpenRetryMs has passed (same throttle form as syncSend). const uint32_t now = platform::millis(); if (lastSyncOpenFailMs_ != 0 && now - lastSyncOpenFailMs_ < kSyncOpenRetryMs) return false; - if (sync == 1) { // send → broadcast destination (configurable port) + if (s == 1) { // send → broadcast destination (configurable port) char bcast[16]; formatDottedQuad(bcast, kBroadcast_); if (syncSock_.open() && syncSock_.connect(bcast, syncPort)) { syncOpen_ = true; diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp index 3fa42823..8d950f7a 100644 --- a/src/core/HttpServerModule.cpp +++ b/src/core/HttpServerModule.cpp @@ -86,9 +86,26 @@ void HttpServerModule::tick20ms() { // slider) by SENDING a {on,bri} text frame over /ws, not by HTTP POST — so we must read // the socket, not only push to it. Cheap (non-blocking, usually nothing pending). pollWledStateFromWebSockets(); - // Accept one HTTP connection per tick. - auto conn = server_.accept(); - if (conn.valid()) handleConnection(conn); + // Accept and serve a bounded BATCH of HTTP connections per tick, not one. A browser page-load opens + // the HTML + several JS/CSS files + the WS upgrade in parallel (~8 connections); accepting one per + // 20 ms tick drains that burst over ~160 ms and — worse — lets the accept backlog fill and drop the + // slower connections (the WS among them), so the page loads but the clock/preview never start until a + // refresh. Draining up to kAcceptsPerTick clears a whole first-load burst in ~2 ticks. It stays bounded + // so one tick can't serve an unbounded run of requests (the hot-path rule): accept() returns an invalid + // connection the instant the backlog is empty, which breaks the loop early in the common idle case. + constexpr int kAcceptsPerTick = 8; + // Bound the batch by WALL-CLOCK too, not just count: each handleConnection serves synchronously and a + // stalled peer can burn up to the write deadline (TcpConnection::write's ~2 s) per connection, so 8 + // stalled clients in one tick could stack to ~16 s — past the task WDT. Break once the batch has spent + // this budget; the remaining backlog drains on the next tick. Subtraction-based compare, rollover-safe. + constexpr uint32_t kAcceptBudgetMs = 100; + const uint32_t batchStart = platform::millis(); + for (int i = 0; i < kAcceptsPerTick; i++) { + auto conn = server_.accept(); + if (!conn.valid()) break; // backlog drained (the usual case: 0 or 1 pending) + handleConnection(conn); + if (platform::millis() - batchStart >= kAcceptBudgetMs) break; // a slow client stalled the batch + } } void HttpServerModule::tick1s() { @@ -571,14 +588,16 @@ void HttpServerModule::serveFileContents(platform::TcpConnection& conn, const ch const int hn = std::snprintf(header, sizeof(header), "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: %ld\r\n" "Connection: close\r\nAccess-Control-Allow-Origin: *\r\n\r\n", size); - conn.write(reinterpret_cast(header), static_cast(hn)); + if (!conn.write(reinterpret_cast(header), static_cast(hn))) return; char chunk[1024]; for (long offset = 0; offset < size;) { const size_t want = static_cast(size - offset) < sizeof(chunk) ? static_cast(size - offset) : sizeof(chunk); const int got = platform::fsReadAt(path, offset, chunk, want); if (got <= 0) break; // read error / early EOF — the client sees a short (truncated) body - conn.write(reinterpret_cast(chunk), static_cast(got)); + // write() returns false on a real socket error or its bounded deadline (a stalled client); STOP + // then — retrying every remaining chunk would burn deadline-worth of render-thread time per chunk. + if (!conn.write(reinterpret_cast(chunk), static_cast(got))) return; offset += got; } } @@ -758,14 +777,16 @@ void HttpServerModule::serveFile(platform::TcpConnection& conn, const char* file "Cache-Control: no-cache\r\n" "\r\n", contentType, size); - conn.write(reinterpret_cast(header), headerLen); + if (!conn.write(reinterpret_cast(header), headerLen)) { std::fclose(f); return; } uint8_t chunk[1024]; while (size > 0) { size_t toRead = size > static_cast(sizeof(chunk)) ? sizeof(chunk) : static_cast(size); size_t bytesRead = std::fread(chunk, 1, toRead, f); if (bytesRead == 0) break; - conn.write(chunk, bytesRead); + // Stop on a write failure (socket error or the bounded deadline for a stalled client) — else + // every remaining chunk retries and burns deadline-worth of render-thread time each. + if (!conn.write(chunk, bytesRead)) break; size -= static_cast(bytesRead); } std::fclose(f); @@ -2147,7 +2168,7 @@ void HttpServerModule::handleWebSocketUpgrade(platform::TcpConnection& conn, con acceptKey); conn.write(reinterpret_cast(response), respLen); - // Store connection as WebSocket client + // Store connection as WebSocket client. for (int i = 0; i < MAX_WS_CLIENTS; i++) { if (!wsClients_[i].valid()) { wsClients_[i] = std::move(conn); @@ -2164,7 +2185,10 @@ void HttpServerModule::handleWebSocketUpgrade(platform::TcpConnection& conn, con return; } } - // No slot available — close + // No slot available — close. A slot frees when a dead client's next send/poll fails (reaped within a + // tick or two), so MAX_WS_CLIENTS is sized well above the realistic concurrent count PLUS the transient + // overlap of a refresh (the browser opens the new socket before the old socket's FIN lands, so both + // briefly hold slots). The browser's own WS backoff retries a genuinely-full moment. conn.close(); } @@ -2262,6 +2286,11 @@ void HttpServerModule::pollWledStateFromWebSockets() { if (!ws.valid()) continue; uint8_t f[512]; int n = ws.read(f, sizeof(f)); // non-blocking (read() returns -1 if nothing) + // read() == 0 is a clean peer close (FIN): reap the slot NOW so it frees for a new client. Without + // this the dead slot lingers until the next SEND fails (up to a tick1s later), and a rapid + // refresh/reconnect burst could find every slot still "valid" and be rejected ("WebSocket closed + // before the connection is established"). read() == -1 (nothing pending) leaves a live client alone. + if (n == 0) { ws.close(); continue; } if (n < 6) continue; // a masked text frame is ≥6 bytes // A fast slider drag can land MULTIPLE small {on,bri} frames in one read; walk every // complete masked text frame in the chunk so none is dropped (apply each in order → diff --git a/src/core/HttpServerModule.h b/src/core/HttpServerModule.h index 018eff04..40d16323 100644 --- a/src/core/HttpServerModule.h +++ b/src/core/HttpServerModule.h @@ -41,7 +41,7 @@ class Scheduler; /// stream through a `JsonSink` — no fixed-buffer ceiling, so a tree of any size serializes correctly. /// /// **WebSocket:** `GET /ws` with `Upgrade: websocket` does the RFC 6455 handshake (SHA-1 + -/// base64), up to 4 concurrent clients. Binary frames take two paths, both without a frame-sized +/// base64), up to `MAX_WS_CLIENTS` (8) concurrent clients. Binary frames take two paths, both without a frame-sized /// buffer: a synchronous stream (`beginBinaryFrame` / `pushBinaryFrame` / `endBinaryFrame`) for a /// forward-only producer, and a resumable buffered send (`sendBufferedFrame`) that drains a /// memory-adaptive chunk per client per `tick20ms` from a stable caller-owned buffer — so a large @@ -237,7 +237,13 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { Scheduler* scheduler_ = nullptr; const char* uiPath_ = "src/ui"; - static constexpr int MAX_WS_CLIENTS = 4; + // Sized for a few concurrent viewers PLUS the transient overlap when one refreshes: the browser opens + // the new WS before the OS delivers the old socket's FIN, so both briefly hold a slot (a dead one is + // reaped within a tick or two on its next failed send/poll). At 4 a couple of quick refreshes could + // fill every slot with not-yet-reaped connections and the new upgrade was rejected ("WebSocket closed + // before the connection is established"); 8 leaves headroom so a refresh always lands a slot. Each slot + // is just an fd + a small cursor, so the array stays tiny. + static constexpr int MAX_WS_CLIENTS = 8; platform::TcpConnection wsClients_[MAX_WS_CLIENTS]; uint32_t wsClientGeneration_ = 0; // ++ on each new WS client; see clientGeneration() diff --git a/src/core/MoonModule.h b/src/core/MoonModule.h index 818ca55a..2748b9d6 100644 --- a/src/core/MoonModule.h +++ b/src/core/MoonModule.h @@ -268,6 +268,11 @@ 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; } + /// 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_(); } void clearControlsRecursive() { controls_.clear(); for (uint8_t i = 0; i < childCount_; i++) children_[i]->clearControlsRecursive(); diff --git a/src/core/PinList.h b/src/core/PinList.h index e2b1b47a..3b5b8312 100644 --- a/src/core/PinList.h +++ b/src/core/PinList.h @@ -12,27 +12,50 @@ namespace mm { // parseDottedQuad (Control.h). Returns nullptr on success or a static error literal the caller feeds // straight into setStatus(). Host-tested by unit_RmtLedDriver_pins.cpp + unit_PinsModule.cpp. -// Parse "18,17,16" into out[0..maxPins). Spaces around tokens are fine (strtol skips them). Rejects -// empty input, bad tokens, trailing commas, duplicates, out-of-range pins (> the chip's MM_MAX_GPIO -// ceiling), and more than maxPins entries (the chip's channel/lane cap). The range check is the -// crash guard: an in-CSV pin like 999 parses as a valid integer but is not a GPIO — handing it to -// IDF's gpio_func_sel() faults ("GPIO number error" → reset, WROVER bench 2026-07-13). Rejecting it -// here — at the one parse boundary every driver (RMT/Parlio/i80) shares — keeps a garbage pin from -// ever reaching hardware, so no driver has to re-guard it. +// Parse "18,17,16" into out[0..maxPins). A token may be a single pin ("17") or an inclusive RANGE +// ("20-23" → 20,21,22,23), and the two mix freely ("20-22,35,38-40") — the same range idiom as the +// IP-destination list (parseIpList), so a human types consecutive pins once. Spaces around tokens are +// fine (strtol skips them). Rejects empty input, bad tokens, trailing commas, duplicates, ranges that +// run backwards (hi < lo), out-of-range pins (> the chip's MM_MAX_GPIO ceiling), and more than maxPins +// entries (the chip's channel/lane cap). The ceiling check is the crash guard: an in-CSV pin like 999 +// parses as a valid integer but is not a GPIO — handing it to IDF's gpio_func_sel() faults ("GPIO number +// error" → reset, WROVER bench 2026-07-13). Rejecting it here — at the one parse boundary every driver +// (RMT/Parlio/i80) shares — keeps a garbage pin from ever reaching hardware, so no driver has to re-guard it. +// +// Append one pin, enforcing the chip ceiling, the duplicate check, and the lane cap. Returns an error +// literal or nullptr; used for both a single token and each step of an expanded range. +inline const char* appendPin(long v, uint16_t* out, uint8_t maxPins, uint8_t& nOut) { + if (v < 0 || v > 0xFFFF) return "invalid pin list"; + if (v > MM_MAX_GPIO) return "pin out of range for this chip"; + if (nOut >= maxPins) return "too many pins for this chip"; + for (uint8_t i = 0; i < nOut; i++) + if (out[i] == static_cast(v)) return "duplicate pin"; + out[nOut++] = static_cast(v); + return nullptr; +} + inline const char* parsePinList(const char* s, uint16_t* out, uint8_t maxPins, uint8_t& nOut) { nOut = 0; if (!s || !*s) return "invalid pin list"; const char* p = s; while (true) { char* end = nullptr; - const long v = std::strtol(p, &end, 10); - if (end == p || v < 0 || v > 0xFFFF) return "invalid pin list"; - if (v > MM_MAX_GPIO) return "pin out of range for this chip"; + const long lo = std::strtol(p, &end, 10); + if (end == p) return "invalid pin list"; + while (*end == ' ') end++; + if (*end == '-') { // a RANGE: expand lo..hi inclusive + const char* after = end + 1; + char* e2 = nullptr; + const long hi = std::strtol(after, &e2, 10); + if (e2 == after) return "invalid pin range"; + if (hi < lo) return "pin range runs backwards"; + for (long v = lo; v <= hi; v++) + if (const char* err = appendPin(v, out, maxPins, nOut)) return err; + end = e2; + } else { + if (const char* err = appendPin(lo, out, maxPins, nOut)) return err; + } while (*end == ' ') end++; - if (nOut >= maxPins) return "too many pins for this chip"; - for (uint8_t i = 0; i < nOut; i++) - if (out[i] == static_cast(v)) return "duplicate pin"; - out[nOut++] = static_cast(v); if (*end == '\0') return nullptr; if (*end != ',') return "invalid pin list"; p = end + 1; diff --git a/src/core/Scheduler.cpp b/src/core/Scheduler.cpp index 1bb8d632..aea629cb 100644 --- a/src/core/Scheduler.cpp +++ b/src/core/Scheduler.cpp @@ -221,6 +221,10 @@ Scheduler::SetControlResult Scheduler::setControl(const char* moduleName, target->markDirty(); if (noteDirtyHook_) noteDirtyHook_(); prepareTree(); + // `enabled` rides the FULL state, not the per-leaf value patch — so the client only learns the new + // value from a full resync. Request one (the same signal a schema change sends); without it the + // client's cached state keeps the old `enabled` and reverts the toggle a second later. + MoonModule::notifySchemaChanged(); return SetControlResult::Ok; } diff --git a/src/light/drivers/Driver.h b/src/light/drivers/Driver.h deleted file mode 100644 index 3c2ad834..00000000 --- a/src/light/drivers/Driver.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -// Umbrella header for writing a driver: include this one file and you have the -// base class plus the output-buffer, correction, and platform pieces a driver -// needs to push the finished image to hardware or the network. A new driver is -// -// #pragma once -// #include "light/drivers/Driver.h" -// namespace mm { -// class MyDriver : public DriverBase { ... }; -// } -// -// DriverBase already bundles Layer, Buffer, Correction, and the platform layer -// (a driver reads the source buffer and applies the output correction), so this -// umbrella is DriverBase plus the small standard headers every driver's status / -// control-name / buffer handling uses. A hardware driver adds its peripheral seam -// (platform::rmt* / platform::parlio* / a socket); a network driver adds its packet -// header — those stay per-driver, since they differ by transport. - -#include "light/drivers/DriverBase.h" // DriverBase + Layer + Buffer + Correction + platform - -#include // std::strcmp (onControlChanged) / memset (buffer clears) -#include // fixed-width ints -#include // std::snprintf for status strings -#include // std::min / max / clamp (chunk loops, size clamps) diff --git a/src/light/drivers/DriverBase.h b/src/light/drivers/DriverBase.h index 43a3a2fd..89982507 100644 --- a/src/light/drivers/DriverBase.h +++ b/src/light/drivers/DriverBase.h @@ -1,5 +1,20 @@ #pragma once +// Include this one file to write a driver: it brings DriverBase plus the output-buffer, correction, and +// platform pieces a driver needs to push the finished image to hardware or the network, so a driver is a +// single include: +// +// #pragma once +// #include "light/drivers/DriverBase.h" +// namespace mm { +// class MyDriver : public DriverBase { ... }; +// } +// +// DriverBase reads the source buffer (Layer/Buffer) and applies the output Correction over the platform +// layer, plus the small standard headers every driver's status / control-name / buffer handling uses. A +// hardware driver adds its peripheral seam (platform::rmt* / platform::parlio* / a socket); a network +// driver adds its packet header — those stay per-driver, since they differ by transport. + #include "core/MoonModule.h" #include "core/ScratchBuffer.h" #include "light/layers/Buffer.h" @@ -8,8 +23,10 @@ #include "light/drivers/LightPresetsModule.h" // the shared preset library a driver references by id #include "platform/platform.h" -#include -#include +#include // std::snprintf for status strings +#include // std::strcmp (onControlChanged) / memset (buffer clears) +#include // fixed-width ints +#include // std::min / max / clamp (chunk loops, size clamps) namespace mm { diff --git a/src/light/drivers/Drivers.h b/src/light/drivers/Drivers.h index f62d13f1..6f8bd887 100644 --- a/src/light/drivers/Drivers.h +++ b/src/light/drivers/Drivers.h @@ -496,6 +496,11 @@ class Drivers : public MoonModule { // dominant CPU cost) and the network send (ArtNet at 16K is the other big one) both leave the // render core. Each driver's tick() is unchanged; only which core calls it differs. void runEncodeLoop() { + // Subscribe THIS (core-1) task to the WDT before feeding it: the subscription is per-task, so the + // taskWdtReset() calls below only count once this task itself is added. Without this, feeding a + // subscription the render task made on a DIFFERENT task is rejected as "task not found" and floods + // the log every frame, starving the network stack (the WS drops → reconnect → full-state → UI churn). + platform::taskWdtSubscribe(); while (!encodeStop_.load(std::memory_order_acquire)) { if (!platform::waitNotify(encodeTask_, 100)) { platform::taskWdtReset(); continue; } if (encodeStop_.load(std::memory_order_acquire)) break; @@ -505,6 +510,7 @@ class Drivers : public MoonModule { platform::taskWdtReset(); encodeDone_.store(true, std::memory_order_release); } + platform::taskWdtUnsubscribe(); // leave the WDT clean before the task exits (no dangling entry) } static void encodeTrampoline(void* self) { static_cast(self)->runEncodeLoop(); } diff --git a/src/light/drivers/HueDriver.h b/src/light/drivers/HueDriver.h index 1eb95667..9e6cde65 100644 --- a/src/light/drivers/HueDriver.h +++ b/src/light/drivers/HueDriver.h @@ -1,6 +1,6 @@ #pragma once -#include "light/drivers/Driver.h" // umbrella: DriverBase + Layer/Buffer/Correction/platform + cstring/cstdint/cstdio/algorithm +#include "light/drivers/DriverBase.h" #include "core/JsonUtil.h" // parse the bridge's JSON responses #include "core/FilesystemModule.h" // noteDirty — persist the app key after pairing diff --git a/src/light/drivers/NetworkSendDriver.h b/src/light/drivers/NetworkSendDriver.h index 3b11f7ae..e64bd89c 100644 --- a/src/light/drivers/NetworkSendDriver.h +++ b/src/light/drivers/NetworkSendDriver.h @@ -1,6 +1,6 @@ #pragma once -#include "light/drivers/Driver.h" // umbrella: DriverBase + Layer/Buffer/Correction/platform + cstring/cstdint/cstdio/algorithm +#include "light/drivers/DriverBase.h" #include "light/ArtNetPacket.h" // shared ArtNet wire formats (build + parse) #include "light/DdpPacket.h" // shared DDP wire format diff --git a/src/light/drivers/ParallelLedDriver.h b/src/light/drivers/ParallelLedDriver.h index 1a75126b..a2d7d91b 100644 --- a/src/light/drivers/ParallelLedDriver.h +++ b/src/light/drivers/ParallelLedDriver.h @@ -110,7 +110,8 @@ class ParallelLedDriver : public DriverBase { static constexpr nrOfLightsType kLoopbackTestLights = 256; /// Comma-separated GPIO list, one parallel lane per pin — up to kMaxLanes strands clocked out - /// SIMULTANEOUSLY, fed consecutive slices of this driver's window. Shared control shape with + /// SIMULTANEOUSLY, fed consecutive slices of this driver's window. A token may be a single pin or an + /// inclusive range ("20-23" → 20,21,22,23), mixing freely ("20-22,35,38-40"). Shared control shape with /// RmtLedDriver (parsers in PinList.h). Defaults live on the derived (chip-specific safe pins), /// so the derived sets them after construction; the base just declares them. i80 needs exactly /// 8 OR 16 real pins (a partial bus is rejected — a sub-16 board parks unused lanes + WR/DC on diff --git a/src/light/drivers/PreviewDriver.h b/src/light/drivers/PreviewDriver.h index 35709ce2..c31051db 100644 --- a/src/light/drivers/PreviewDriver.h +++ b/src/light/drivers/PreviewDriver.h @@ -1,6 +1,6 @@ #pragma once -#include "light/drivers/Driver.h" // umbrella: DriverBase + Layer/Buffer/Correction/platform + cstring/cstdint/cstdio/algorithm +#include "light/drivers/DriverBase.h" #include "light/light_types.h" // lengthType, nrOfLightsType #include "core/BinaryBroadcaster.h" @@ -63,9 +63,13 @@ class PreviewDriver : public DriverBase { bool hasCorrectionControls() const override { return false; } /// Bind the controls: `fps` (1-60) and `resumableFrames` (the downsampled-frame transport A/B). + /// resumableFrames is an EXPERT control — a dev/tuning A/B, not a knob a normal user should touch (its + /// ON leg tears the preview until the slot-sharing fix lands), so it shows only when System.expertMode + /// is on. It still persists and still accepts API writes; only the default UI hides it. void defineDriverControls() override { controls_.addUint8("fps", fps, 1, 60); controls_.addBool("resumableFrames", resumableFrames); + controls_.setAdvanced(controls_.count() - 1); } /// Point the driver at the sparse driver buffer the LED/ArtNet drivers also read @@ -258,10 +262,12 @@ class PreviewDriver : public DriverBase { } else { struct CountCtx { nrOfLightsType s, out; }; CountCtx cc{s, 0}; - layouts->forEachCoord([](void* c, nrOfLightsType, lengthType x, lengthType y, lengthType z) { + // A gap is a real preview position (drawn dark at its (x,y,z)), so count/emit it like any + // light — blackCb null → blackPixel falls back to the same handler. + layouts->forEachCoord(CoordSink{[](void* c, nrOfLightsType, lengthType x, lengthType y, lengthType z) { auto* p = static_cast(c); if (x % p->s == 0 && y % p->s == 0 && z % p->s == 0) p->out++; - }, &cc); + }, nullptr, &cc}); coordCount_ = cc.out; // Size the kept-index cache to EXACTLY this count (grow-only) BEFORE the emit pass fills it, // so the cache can never truncate: coordCount_ is recomputed every rebuild (adaptive stride, @@ -324,14 +330,14 @@ class PreviewDriver : public DriverBase { // gather then loops this index map instead of re-walking forEachCoord over every light // (an O(total-lights) callback walk per firing, measured ~8 ms at 12K lights on the // encode worker). The map's lifecycle IS the coord table's: same pass, same invalidation. - layouts->forEachCoord([](void* c, nrOfLightsType idx, lengthType x, lengthType y, lengthType z) { + layouts->forEachCoord(CoordSink{[](void* c, nrOfLightsType idx, lengthType x, lengthType y, lengthType z) { auto* p = static_cast(c); if (x % p->s != 0 || y % p->s != 0 || z % p->s != 0) return; PreviewDriver* self = p->self; if (self->keptIdx_ && self->keptCount_ < self->keptIdxCap_) self->keptIdx_[self->keptCount_++] = idx; p->emit(x, y, z); - }, &pc); + }, nullptr, &pc}); } if (pc.fill) broadcaster_->pushBinaryFrame(pc.buf, pc.fill); // The coord table must reach the browser before color frames carrying the new count (the @@ -425,11 +431,11 @@ class PreviewDriver : public DriverBase { // Fallback (index-map alloc miss): the full lattice walk, s as the FULL stride (not // clamped) — must match buildAndSendCoordTable's. struct Skip { ColCtx* col; nrOfLightsType s; } sk{&col, s}; - layer_->layouts()->forEachCoord([](void* c, nrOfLightsType idx, lengthType x, lengthType y, lengthType z) { + layer_->layouts()->forEachCoord(CoordSink{[](void* c, nrOfLightsType idx, lengthType x, lengthType y, lengthType z) { auto* p = static_cast(c); if (x % p->s != 0 || y % p->s != 0 || z % p->s != 0) return; p->col->emit(idx); - }, &sk); + }, nullptr, &sk}); } if (resumable) return broadcaster_->sendBufferedFrame(header, sizeof(header), stage_, bodyBytes); if (col.fill) broadcaster_->pushBinaryFrame(col.buf, col.fill); @@ -480,7 +486,15 @@ class PreviewDriver : public DriverBase { clearStatus(); } } - bool resumableFrames = true; // A/B: downsampled frame via resumable send (ON) vs synchronous (OFF) + // A/B for the downsampled-frame transport: OFF = synchronous begin/push/end stream (blocking socket + // writes on the render thread, proven-correct); ON = gather-then-resumable-send that drains off the + // render thread. Defaults OFF: the resumable path shares the single-occupancy send slot with the ~1 Hz + // full-state push and the next preview frame, so a preempted mid-drain frame reaches the browser + // spliced — a visibly TORN preview (top rows new, the rest stale). The off-thread send is only worth it + // to avoid the ~17 ms render hitch at very large grids with the preview open; until the slot-sharing + // tear is fixed (give preview its own send slot, or drop a preempted drain cleanly), the correct + // synchronous path is the default. Kept in-tree as the A/B reference for that fix. + bool resumableFrames = false; bool stageAllocFailed_ = false; // resumable staging buffer couldn't allocate → synchronous fallback bool keptIdxAllocFailed_ = false; // index cache couldn't allocate → per-frame lattice walk uint8_t* stage_ = nullptr; // gathered color payload for the resumable send (see ensureStage) diff --git a/src/light/drivers/RmtLedDriver.h b/src/light/drivers/RmtLedDriver.h index c54fb64e..826a723b 100644 --- a/src/light/drivers/RmtLedDriver.h +++ b/src/light/drivers/RmtLedDriver.h @@ -1,6 +1,6 @@ #pragma once -#include "light/drivers/Driver.h" // umbrella: DriverBase + Layer/Buffer/Correction/platform + cstring/cstdint/cstdio/algorithm +#include "light/drivers/DriverBase.h" #include "light/drivers/LedDriverConfig.h" #include "light/drivers/PinList.h" // parsePinList / assignCounts (shared with MultiPinLedDriver) diff --git a/src/light/effects/AudioSpectrumEffect.h b/src/light/effects/AudioSpectrumEffect.h index eb7d38cb..44278053 100644 --- a/src/light/effects/AudioSpectrumEffect.h +++ b/src/light/effects/AudioSpectrumEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/AudioVolumeEffect.h b/src/light/effects/AudioVolumeEffect.h index 10451a94..f21e0726 100644 --- a/src/light/effects/AudioVolumeEffect.h +++ b/src/light/effects/AudioVolumeEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/BlurzEffect.h b/src/light/effects/BlurzEffect.h index 6f826e49..6919572f 100644 --- a/src/light/effects/BlurzEffect.h +++ b/src/light/effects/BlurzEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/BouncingBallsEffect.h b/src/light/effects/BouncingBallsEffect.h index caccd0bf..ba3eb8bd 100644 --- a/src/light/effects/BouncingBallsEffect.h +++ b/src/light/effects/BouncingBallsEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/DemoReelEffect.h b/src/light/effects/DemoReelEffect.h index 91ee3014..103a785b 100644 --- a/src/light/effects/DemoReelEffect.h +++ b/src/light/effects/DemoReelEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" #include "core/ModuleFactory.h" // enumerate + create the effects to cycle through #include "light/fonts.h" // fonts::kFont4x6 — the overlay font diff --git a/src/light/effects/DistortionWavesEffect.h b/src/light/effects/DistortionWavesEffect.h index e9f12832..67fab9a9 100644 --- a/src/light/effects/DistortionWavesEffect.h +++ b/src/light/effects/DistortionWavesEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/Effect.h b/src/light/effects/Effect.h deleted file mode 100644 index ec7c7e88..00000000 --- a/src/light/effects/Effect.h +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once - -// Umbrella header for writing an effect: include this one file and you have the -// effect author's standard library — the base class, the render context accessors, -// the common drawing / colour / maths helpers, scratch memory, and the audio source. -// A new effect is -// -// #pragma once -// #include "light/effects/Effect.h" -// namespace mm { -// class MyEffect : public EffectBase { ... }; -// } -// -// (Effect.h, not EffectBase.h: EffectBase forward-declares Layer and its accessors -// are defined in Layer.h, so an effect must pull in Layer.h anyway — this umbrella -// does that plus the helpers, so the author writes one line.) -// -// The set is deliberately the WHOLE surface available to an effect, not the minimal -// set: the render context, the draw/palette/math/noise primitives, scratch memory -// (ScratchBuffer), the audio spectrum (AudioService/AudioFrame), the crc fingerprint, -// and the / the bodies use. This is also the surface a scripted -// MoonLive effect gets uniformly, and it costs zero firmware bytes (unused declarations -// emit no code). An effect that needs a helper genuinely OUTSIDE this surface — a font -// table, the module factory, a network packet format, a platform primitive — adds that -// one extra include; nothing else should appear at the top of an effect header. - -#include "light/layers/Layer.h" // EffectBase + its accessors (layer/buffer/width/height/…) -#include "light/draw.h" // draw::pixel / fill / line / fade / blur — write pixels by coordinate -#include "light/Palette.h" // colorFromPalette, Palettes::active — the palette system -#include "core/math8.h" // beat8 / beatsin8 / sin8 / random8 — the integer animation helpers -#include "core/noise.h" // inoise8 — the shared value-noise field -#include "core/color.h" // RGB -#include "core/crc.h" // crc16 — grid/state fingerprints (stasis detection) -#include "core/ScratchBuffer.h" // ScratchBuffer — self-sizing scratch memory for stateful effects -#include "core/AudioService.h" // AudioService::latestFrame() — the shared audio source -#include "core/AudioFrame.h" // AudioFrame — level + 16-band spectrum an audio-reactive effect reads - -#include // memset / memcpy / strcmp — buffer + control-name handling -#include // sqrtf / sinf / log10f — per-frame float maths (never per-light) -#include // fixed-width ints — the phase accumulators / packed state effects use -#include // std::array — fixed-size effect state tables (cube faces, LUTs) diff --git a/src/light/effects/EffectBase.h b/src/light/effects/EffectBase.h index 3971d41a..70022557 100644 --- a/src/light/effects/EffectBase.h +++ b/src/light/effects/EffectBase.h @@ -1,5 +1,29 @@ #pragma once +// Include this one file to write an effect: it brings EffectBase, the render context accessors, and the +// common drawing / palette / maths / noise / colour / scratch / audio helpers, so a new effect is a single +// include: +// +// #pragma once +// #include "light/effects/EffectBase.h" +// namespace mm { +// class MyEffect : public EffectBase { ... }; +// } +// +// The helper set is the WHOLE surface available to an effect — the render context, the draw/palette/math/ +// noise primitives, scratch memory (ScratchBuffer), the audio spectrum (AudioService/AudioFrame), the crc +// fingerprint, and the / the bodies use. This is also the surface a scripted MoonLive effect +// gets uniformly, and it costs zero firmware bytes (unused declarations emit no code). An effect that needs a +// helper genuinely OUTSIDE this surface — a font table, the module factory, a network packet format, a +// platform primitive — adds that one extra include; nothing else should appear at the top of an effect header. +// +// Note the layout: the helper includes (chiefly light/layers/Layer.h, which defines EffectBase's accessor +// bodies) are pulled in at the BOTTOM of this file, AFTER the EffectBase class. Layer.h includes EffectBase.h +// (a Layer holds effect children), so EffectBase.h can only forward-declare Layer up here; putting Layer.h +// after the class lets it re-enter (a no-op via the include guard) with EffectBase already complete, so its +// out-of-line accessor definitions compile — the standard forward-declare-then-include-the-definer pattern +// that breaks the cycle. + #include "core/MoonModule.h" #include "light/light_types.h" // lengthType, nrOfLightsType, Dim @@ -7,7 +31,7 @@ namespace mm { -class Layer; // forward declaration +class Layer; // forward declaration (defined in light/layers/Layer.h, included at the bottom) // Dim enum lives in light/light_types.h so both EffectBase and ModifierBase can // refer to it without including each other. Used by Layer::extrude to fill unused @@ -86,3 +110,20 @@ class EffectBase : public MoonModule { }; } // namespace mm + +// --- The effect author's helper surface (see the file header). Pulled in AFTER the EffectBase class so +// Layer.h — which includes this file — re-enters harmlessly and completes EffectBase's accessor bodies. --- +#include "light/layers/Layer.h" // EffectBase's out-of-line accessors (layer/buffer/width/height/…) +#include "light/draw.h" // draw::pixel / fill / line / fade / blur — write pixels by coordinate +#include "light/Palette.h" // colorFromPalette, Palettes::active — the palette system +#include "core/math8.h" // beat8 / beatsin8 / sin8 / random8 — the integer animation helpers +#include "core/noise.h" // inoise8 — the shared value-noise field +#include "core/color.h" // RGB +#include "core/crc.h" // crc16 — grid/state fingerprints (stasis detection) +#include "core/ScratchBuffer.h" // ScratchBuffer — self-sizing scratch memory for stateful effects +#include "core/AudioService.h" // AudioService::latestFrame() — the shared audio source +#include "core/AudioFrame.h" // AudioFrame — level + 16-band spectrum an audio-reactive effect reads + +#include // memset / memcpy / strcmp — buffer + control-name handling +#include // sqrtf / sinf / log10f — per-frame float maths (never per-light) +#include // std::array — fixed-size effect state tables (cube faces, LUTs) diff --git a/src/light/effects/FireEffect.h b/src/light/effects/FireEffect.h index afc52f87..b887f918 100644 --- a/src/light/effects/FireEffect.h +++ b/src/light/effects/FireEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/FixedRectangleEffect.h b/src/light/effects/FixedRectangleEffect.h index 13285fd5..e26877da 100644 --- a/src/light/effects/FixedRectangleEffect.h +++ b/src/light/effects/FixedRectangleEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/FreqMatrixEffect.h b/src/light/effects/FreqMatrixEffect.h index 9e51c417..bf74800c 100644 --- a/src/light/effects/FreqMatrixEffect.h +++ b/src/light/effects/FreqMatrixEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/FreqSawsEffect.h b/src/light/effects/FreqSawsEffect.h index 1b02d5a9..259743eb 100644 --- a/src/light/effects/FreqSawsEffect.h +++ b/src/light/effects/FreqSawsEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/GEQ3DEffect.h b/src/light/effects/GEQ3DEffect.h index c3694bd2..4bf77087 100644 --- a/src/light/effects/GEQ3DEffect.h +++ b/src/light/effects/GEQ3DEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/GEQEffect.h b/src/light/effects/GEQEffect.h index 15487909..e6e74752 100644 --- a/src/light/effects/GEQEffect.h +++ b/src/light/effects/GEQEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/GameOfLifeEffect.h b/src/light/effects/GameOfLifeEffect.h index 6f82ac01..b1fabc13 100644 --- a/src/light/effects/GameOfLifeEffect.h +++ b/src/light/effects/GameOfLifeEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/LavaLampEffect.h b/src/light/effects/LavaLampEffect.h index 6c040c0d..5e1e903e 100644 --- a/src/light/effects/LavaLampEffect.h +++ b/src/light/effects/LavaLampEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/LinesEffect.h b/src/light/effects/LinesEffect.h index f1b5094a..a6bbc8f4 100644 --- a/src/light/effects/LinesEffect.h +++ b/src/light/effects/LinesEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/LissajousEffect.h b/src/light/effects/LissajousEffect.h index 4ce86e68..e9a8100a 100644 --- a/src/light/effects/LissajousEffect.h +++ b/src/light/effects/LissajousEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/MetaballsEffect.h b/src/light/effects/MetaballsEffect.h index 4e6ea835..d69dae0c 100644 --- a/src/light/effects/MetaballsEffect.h +++ b/src/light/effects/MetaballsEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/NetworkReceiveEffect.h b/src/light/effects/NetworkReceiveEffect.h index 60e6d3fd..dd927845 100644 --- a/src/light/effects/NetworkReceiveEffect.h +++ b/src/light/effects/NetworkReceiveEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" #include "light/ArtNetPacket.h" // shared ArtNet wire formats (build + parse) #include "light/DdpPacket.h" // shared DDP wire format @@ -99,25 +99,25 @@ class NetworkReceiveEffect : public EffectBase { if (n <= 0) break; if (parseArtDmxPacket(pkt_, static_cast(n), universe, data, dataLen)) { applyDmx(universe, data, dataLen); - noteReceiving(kStatusArtnet); + noteReceiving("Art-Net", srcIp); } else if (isArtPoll(pkt_, static_cast(n))) { replyToPoll(srcIp); // make the device show up in controller node lists } } for (int i = 0; i < kMaxPacketsPerTick; i++) { - const int n = e131Socket_.recvFrom(pkt_, sizeof(pkt_)); + const int n = e131Socket_.recvFrom(pkt_, sizeof(pkt_), srcIp); if (n <= 0) break; if (parseE131Packet(pkt_, static_cast(n), universe, data, dataLen)) { applyDmx(universe, data, dataLen); - noteReceiving(kStatusE131); + noteReceiving("E1.31", srcIp); } } for (int i = 0; i < kMaxPacketsPerTick; i++) { - const int n = ddpSocket_.recvFrom(pkt_, sizeof(pkt_)); + const int n = ddpSocket_.recvFrom(pkt_, sizeof(pkt_), srcIp); if (n <= 0) break; if (parseDdpPacket(pkt_, static_cast(n), byteOffset, data, dataLen)) { applyBytes(byteOffset, data, dataLen); - noteReceiving(kStatusDdp); + noteReceiving("DDP", srcIp); } } // Staging → layer buffer (the layer cleared it at tick start). @@ -158,9 +158,15 @@ class NetworkReceiveEffect : public EffectBase { private: static constexpr int kMaxPacketsPerTick = 128; static constexpr const char* kBindFailMsg = "UDP bind failed — port in use?"; - static constexpr const char* kStatusArtnet = "receiving Art-Net"; - static constexpr const char* kStatusE131 = "receiving E1.31"; - static constexpr const char* kStatusDdp = "receiving DDP"; + // The receiving-status buffer: "receiving from ", the ONE writable status this effect + // shows — the same field reports the protocol AND the sender IP, so it answers "who is driving me". + // setStatus holds the pointer (doesn't copy), so the string must outlive the call, hence a member, not + // a stack buffer. Sized for the longest form ("receiving Art-Net from 255.255.255.255" = 38 chars + + // NUL). A bind error uses its own static literal. lastProto_/lastIp_ cache the last-formatted source so + // noteReceiving can early-out on the common case (same sender + protocol) without even formatting. + char recvStatus_[40] = ""; + const char* lastProto_ = nullptr; // last protocol pointer the status was built from (literal, stable) + uint8_t lastIp_[4] = {}; // last sender IP the status was built from platform::UdpSocket artnetSocket_; platform::UdpSocket e131Socket_; @@ -170,13 +176,22 @@ class NetworkReceiveEffect : public EffectBase { // freed on disable via MoonModule::release() (the release() override chains to it). ScratchBuffer staging_{*this}; - // Swap the "receiving " diagnostic — but never clobber a bind - // error. Pointer compares only (all four strings are static literals). - void noteReceiving(const char* lit) { + // Update the "receiving from " diagnostic, but never clobber a bind error (its status is + // the kBindFailMsg literal, so status() != recvStatus_). The common case is the same sender + protocol + // every packet, so short-circuit on the cached lastProto_/lastIp_ FIRST — no formatting at all — and + // only snprintf + setStatus when the source actually changes. proto is always one of the same three + // string literals, so a pointer compare identifies it. This runs on the receive hot path. + void noteReceiving(const char* proto, const uint8_t ip[4]) { const char* s = status(); - if (s == nullptr || s == kStatusArtnet || s == kStatusE131 || s == kStatusDdp) { - if (s != lit) setStatus(lit, Severity::Status); - } + if (s != nullptr && s != recvStatus_) return; // a bind error (or foreign status) wins + // Already showing this exact source? nothing to do (skip the format + the setStatus). + if (s == recvStatus_ && proto == lastProto_ && std::memcmp(ip, lastIp_, 4) == 0) return; + lastProto_ = proto; + std::memcpy(lastIp_, ip, 4); + std::snprintf(recvStatus_, sizeof(recvStatus_), "receiving %s from %u.%u.%u.%u", proto, + static_cast(ip[0]), static_cast(ip[1]), + static_cast(ip[2]), static_cast(ip[3])); + setStatus(recvStatus_, Severity::Status); } // Answer an ArtPoll with our IP/MAC/name so controllers list the device. diff --git a/src/light/effects/Noise2DEffect.h b/src/light/effects/Noise2DEffect.h index 8b18b026..64ac1d4e 100644 --- a/src/light/effects/Noise2DEffect.h +++ b/src/light/effects/Noise2DEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/NoiseEffect.h b/src/light/effects/NoiseEffect.h index b35df4eb..bca72b0b 100644 --- a/src/light/effects/NoiseEffect.h +++ b/src/light/effects/NoiseEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/NoiseMeterEffect.h b/src/light/effects/NoiseMeterEffect.h index 46d258f8..8032b083 100644 --- a/src/light/effects/NoiseMeterEffect.h +++ b/src/light/effects/NoiseMeterEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/PaintBrushEffect.h b/src/light/effects/PaintBrushEffect.h index cd376353..04b3ba06 100644 --- a/src/light/effects/PaintBrushEffect.h +++ b/src/light/effects/PaintBrushEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/ParticlesEffect.h b/src/light/effects/ParticlesEffect.h index 87a17423..5e6fbb1c 100644 --- a/src/light/effects/ParticlesEffect.h +++ b/src/light/effects/ParticlesEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/PlasmaEffect.h b/src/light/effects/PlasmaEffect.h index 2e8eb0b7..bed658fa 100644 --- a/src/light/effects/PlasmaEffect.h +++ b/src/light/effects/PlasmaEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/PraxisEffect.h b/src/light/effects/PraxisEffect.h index 06366986..861b7b83 100644 --- a/src/light/effects/PraxisEffect.h +++ b/src/light/effects/PraxisEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath/cstdint/array +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/RainbowEffect.h b/src/light/effects/RainbowEffect.h index 3f76e70d..92f4314c 100644 --- a/src/light/effects/RainbowEffect.h +++ b/src/light/effects/RainbowEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/RandomEffect.h b/src/light/effects/RandomEffect.h index 25508c19..98b1be19 100644 --- a/src/light/effects/RandomEffect.h +++ b/src/light/effects/RandomEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/RingsEffect.h b/src/light/effects/RingsEffect.h index fa745a55..5a83daea 100644 --- a/src/light/effects/RingsEffect.h +++ b/src/light/effects/RingsEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/RipplesEffect.h b/src/light/effects/RipplesEffect.h index 7933f4f9..b9a13b2d 100644 --- a/src/light/effects/RipplesEffect.h +++ b/src/light/effects/RipplesEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/RubiksCubeEffect.h b/src/light/effects/RubiksCubeEffect.h index 409a4455..848a8c6a 100644 --- a/src/light/effects/RubiksCubeEffect.h +++ b/src/light/effects/RubiksCubeEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath/cstdint/array +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/SineEffect.h b/src/light/effects/SineEffect.h index 88d444ec..f0ae5d7f 100644 --- a/src/light/effects/SineEffect.h +++ b/src/light/effects/SineEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/SolidEffect.h b/src/light/effects/SolidEffect.h index 44cfe492..bf540f0d 100644 --- a/src/light/effects/SolidEffect.h +++ b/src/light/effects/SolidEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/SphereMoveEffect.h b/src/light/effects/SphereMoveEffect.h index 74838a07..fe545750 100644 --- a/src/light/effects/SphereMoveEffect.h +++ b/src/light/effects/SphereMoveEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/SpiralEffect.h b/src/light/effects/SpiralEffect.h index aae2bbd7..1ffa0a47 100644 --- a/src/light/effects/SpiralEffect.h +++ b/src/light/effects/SpiralEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/StarFieldEffect.h b/src/light/effects/StarFieldEffect.h index b4abe0ff..23943804 100644 --- a/src/light/effects/StarFieldEffect.h +++ b/src/light/effects/StarFieldEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/StarSkyEffect.h b/src/light/effects/StarSkyEffect.h index 3e395499..e1ecdd03 100644 --- a/src/light/effects/StarSkyEffect.h +++ b/src/light/effects/StarSkyEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/effects/TetrixEffect.h b/src/light/effects/TetrixEffect.h index ef7d43e5..5262890d 100644 --- a/src/light/effects/TetrixEffect.h +++ b/src/light/effects/TetrixEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" #include "platform/platform.h" // platform::millis (the per-drop start-delay clock) diff --git a/src/light/effects/TextEffect.h b/src/light/effects/TextEffect.h index 839a5f88..80471b08 100644 --- a/src/light/effects/TextEffect.h +++ b/src/light/effects/TextEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" #include "light/fonts.h" // fonts::kAll — the selectable bitmap fonts diff --git a/src/light/effects/WaveEffect.h b/src/light/effects/WaveEffect.h index e6d92cae..a38738b3 100644 --- a/src/light/effects/WaveEffect.h +++ b/src/light/effects/WaveEffect.h @@ -1,6 +1,6 @@ #pragma once -#include "light/effects/Effect.h" // umbrella: EffectBase + render context + draw/palette/math/noise/color/crc/ScratchBuffer/audio + cstring/cmath +#include "light/effects/EffectBase.h" namespace mm { diff --git a/src/light/layers/Layer.h b/src/light/layers/Layer.h index 47aa97ce..6db5ebf8 100644 --- a/src/light/layers/Layer.h +++ b/src/light/layers/Layer.h @@ -101,15 +101,16 @@ class Layer : public MoonModule { return; // applyState() recurses to the effects next } - // Compute physical dimensions from layout + // Compute physical dimensions from layout. Gaps count toward the box (a black pixel occupies a + // real position), so one callback handles both kinds — blackCb null → blackPixel falls back. struct DimCtx { lengthType maxX, maxY, maxZ; }; DimCtx dctx{0, 0, 0}; - layouts_->forEachCoord([](void* ctx, nrOfLightsType, lengthType x, lengthType y, lengthType z) { + layouts_->forEachCoord(CoordSink{[](void* ctx, nrOfLightsType, lengthType x, lengthType y, lengthType z) { auto* d = static_cast(ctx); if (x > d->maxX) d->maxX = x; if (y > d->maxY) d->maxY = y; if (z > d->maxZ) d->maxZ = z; - }, &dctx); + }, nullptr, &dctx}); physicalWidth_ = dctx.maxX + 1; physicalHeight_ = dctx.maxY + 1; physicalDepth_ = dctx.maxZ + 1; @@ -357,11 +358,15 @@ class Layer : public MoonModule { const nrOfLightsType logicalCount = cellCount(logical); const nrOfLightsType driverCount = physicalLightCount(); // == Layouts::totalLightCount() const bool dense = (driverCount == boxCount); + // A gap fills a box cell (dense stays true) but must NOT receive that cell's color, so the + // identity map — which lights every cell — is wrong when gaps exist. Route to the folded + // build, which drops the gap slots from the LUT (they stay black). No gaps → unchanged. + const bool anyGap = layouts_ && layouts_->hasBlackPixels(); - // Fast path — no static modifiers, dense grid in natural order: box cell i + // Fast path — no static modifiers, dense grid in natural order, no gaps: box cell i // IS driver light i, so the mapping is the identity memcpy. This is the FPS // floor for the common case; keep it before any allocation. - if (staticCount == 0 && dense && isNaturalOrder()) { + if (staticCount == 0 && dense && !anyGap && isNaturalOrder()) { lut_.setIdentity(boxCount); allocateBuffer(boxCount); return; @@ -373,7 +378,10 @@ class Layer : public MoonModule { // so each physical light contributes at most ONE destination — maxDest is // exactly driverCount, no product, no overflow ceiling. if (!buildFoldedLUT(logical, logicalCount, driverCount)) { - // OOM in the fold build — degrade to identity (correct, not crash). + // OOM in the fold build — degrade to identity (safe, not crash). One visual caveat: a + // gapped layout's dark columns light up in this degraded state (the identity map has no + // way to drop them), but a Warning is surfaced and the device keeps running — the + // robustness principle's "degraded, not crashed" applied to an out-of-memory build. lutSkipped_ = true; setStatus("modifier mapping skipped — not enough memory", Severity::Warning); width_ = physicalWidth_; height_ = physicalHeight_; depth_ = physicalDepth_; @@ -396,13 +404,15 @@ class Layer : public MoonModule { bool isNaturalOrder() const { struct Ctx { lengthType w, h; bool ok; }; Ctx ctx{physicalWidth_, physicalHeight_, true}; - layouts_->forEachCoord([](void* c, nrOfLightsType driverIdx, lengthType x, lengthType y, lengthType z) { + // Only reached for a gap-free layout (a gap routes to the folded build before this is asked), + // so blackCb is null and gaps, were there any, would fall back to the same order check. + layouts_->forEachCoord(CoordSink{[](void* c, nrOfLightsType driverIdx, lengthType x, lengthType y, lengthType z) { auto* k = static_cast(c); if (!k->ok) return; // once a mismatch is found the answer is settled; skip the rest nrOfLightsType box = static_cast(z) * k->w * k->h + static_cast(y) * k->w + x; if (driverIdx != box) k->ok = false; - }, &ctx); + }, nullptr, &ctx}); return ctx.ok; } @@ -467,8 +477,16 @@ class Layer : public MoonModule { else f->counts[li]++; // pass A: bump the count }; + // A GAP (black pixel) is DROPPED from the LUT: its physical slot is already counted in + // driverCount, but no logical cell maps to it, so the scatter never writes it and it stays + // black (blendMap clears first). The black handler is therefore a no-op — this is exactly the + // "physical pixel that stays black" the feature is: a wire slot present, but no source. So both + // passes use one sink whose blackCb does nothing. + static constexpr CoordCallback kDropGap = + [](void*, nrOfLightsType, lengthType, lengthType, lengthType) {}; + // Pass A — count. - layouts_->forEachCoord(onCoord, &fctx); + layouts_->forEachCoord(CoordSink{onCoord, kDropGap, &fctx}); // Prefix-sum counts → offsets (counts[li] becomes the start of cell li's run). nrOfLightsType running = 0; @@ -481,7 +499,7 @@ class Layer : public MoonModule { // Pass B — scatter. counts[] is now the per-cell write cursor (offsets advance). fctx.scatter = true; - layouts_->forEachCoord(onCoord, &fctx); + layouts_->forEachCoord(CoordSink{onCoord, kDropGap, &fctx}); // Pass B advanced each cell's cursor to the END of its run, so counts[i] now // holds the end offset of cell i — which equals the START offset of cell i+1. diff --git a/src/light/layouts/CarLightsLayout.h b/src/light/layouts/CarLightsLayout.h index 12efddcf..9d93ad5f 100644 --- a/src/light/layouts/CarLightsLayout.h +++ b/src/light/layouts/CarLightsLayout.h @@ -1,6 +1,6 @@ #pragma once -#include "light/layouts/Layout.h" // umbrella: LayoutBase + light_types + math8 + cmath/cstdint/limits/numbers +#include "light/layouts/LayoutBase.h" namespace mm { @@ -53,8 +53,8 @@ class CarLightsLayout : public LayoutBase { return n; } - void forEachCoord(CoordCallback cb, void* ctx) const override { - walk(cb, ctx, nullptr); + void forEachCoord(const CoordSink& sink) const override { + walk(sink.cb, sink.ctx, nullptr); } private: diff --git a/src/light/layouts/CubeLayout.h b/src/light/layouts/CubeLayout.h index afa80684..e66e20b6 100644 --- a/src/light/layouts/CubeLayout.h +++ b/src/light/layouts/CubeLayout.h @@ -1,6 +1,6 @@ #pragma once -#include "light/layouts/Layout.h" // umbrella: LayoutBase + light_types + math8 + cmath/cstdint/limits/numbers +#include "light/layouts/LayoutBase.h" namespace mm { @@ -72,7 +72,7 @@ class CubeLayout : public LayoutBase { return static_cast(n > kMax ? kMax : n); } - void forEachCoord(CoordCallback cb, void* ctx) const override { + void forEachCoord(const CoordSink& sink) const override { // Choose the axis order (which loop slot drives which axis). axes[0] is // the OUTERMOST loop's axis, axes[2] the innermost (fastest). Verbatim // from MoonLight: index 0=X,1=Y,2=Z. Guard an out-of-range select. @@ -116,7 +116,7 @@ class CubeLayout : public LayoutBase { coords[a0] = v0; coords[a1] = v1; coords[a2] = v2; - cb(ctx, static_cast(idx++), + sink.pixel(static_cast(idx++), coords[0], coords[1], coords[2]); } } diff --git a/src/light/layouts/GridBlacksLayout.h b/src/light/layouts/GridBlacksLayout.h new file mode 100644 index 00000000..93635e15 --- /dev/null +++ b/src/light/layouts/GridBlacksLayout.h @@ -0,0 +1,76 @@ +#pragma once + +#include "light/layouts/LayoutBase.h" + +namespace mm { + +// A dense 3D grid with mid-strand DARK COLUMNS: the columns [blackStart, blackStart+blackCount) are +// held black in every row. A dark column is a GAP — a physical wire slot the driver still clocks (so +// WS2812 data flows THROUGH the unlit LEDs to reach the lit columns beyond) that maps to no logical +// light, so it stays black. This is for a sealed/continuous panel with a dark spacer strip, or a slat +// wall, where the strip cannot be cut and the effect must map across the gap unshifted (the picture is +// HOLED at its true coordinates, not squeezed). It is the plain [Grid](GridLayout.md) with one added +// capability; a grid without dark columns is just a Grid, so pick that. +// +// The gap decision is made once, at the emit site (sink.blackPixel vs sink.pixel), on the TRUE column +// x — so a dark column stays dark whichever way a serpentine strip snakes into the row. hasBlackPixels +// tells the Layer to build the folded LUT (which drops the gap slots) instead of the dense identity map +// (which would light them). See CoordSink for the two-kinds-of-pixel model. +/// Layout of a dense 3D grid with mid-strand dark columns (a spacer). +class GridBlacksLayout : public LayoutBase { +public: + lengthType width = 16; + lengthType height = 16; + lengthType depth = 1; + bool serpentine = false; // odd rows wired in reverse (boustrophedon) — the snaked-strip matrix. + lengthType blackStart = 0; // first dark column + lengthType blackCount = 0; // number of dark columns; 0 = no gap (renders like a plain Grid) + + void defineControls() override { + controls_.addInt16("width", width, 1, 512); + controls_.addInt16("height", height, 1, 512); + controls_.addInt16("depth", depth, 1, 512); + controls_.addBool("serpentine", serpentine); + controls_.addInt16("blackCount", blackCount, 0, 512); + controls_.addInt16("blackStart", blackStart, 0, 512); + controls_.setHidden(controls_.count() - 1, blackCount == 0); // blackStart matters only with a run + } + + nrOfLightsType lightCount() const override { + // Multiply in uint32_t to detect overflow before casting. A gap is a PHYSICAL pixel (a wire slot + // the driver clocks), so it counts here — dark and lit cells alike fill the box. + uint32_t n = static_cast(width) * height * depth; + constexpr uint32_t kMax = std::numeric_limits::max(); + return static_cast(n > kMax ? kMax : n); + } + + // A non-empty run means dark columns exist, so the Layer must fold the LUT (dropping the gap slots) + // rather than take the identity fast path (which would light them). No run → renders like a Grid. + bool hasBlackPixels() const override { return blackCount != 0; } + + void forEachCoord(const CoordSink& sink) const override { + // uint32_t idx so it never wraps on uint16_t nrOfLightsType (no-PSRAM 512×512 > 65535); stop at + // the clamped lightCount() so emitted indices stay within the allocated buffer. + const uint32_t limit = lightCount(); + const lengthType blackEnd = static_cast(blackStart + blackCount); // exclusive + uint32_t idx = 0; + for (lengthType z = 0; z < depth && idx < limit; z++) { + for (lengthType y = 0; y < height && idx < limit; y++) { + // Serpentine reverses the wire order on odd rows; the emitted COORDINATE is still the + // true (x,y,z) — only the index→position order changes. + const bool reverse = serpentine && (y & 1); + for (lengthType i = 0; i < width && idx < limit; i++) { + const lengthType x = reverse ? static_cast(width - 1 - i) : i; + // Gap test on the true x (the physical column), decided HERE at the emit site so it + // is never re-derived from the index elsewhere. + if (blackCount != 0 && x >= blackStart && x < blackEnd) + sink.blackPixel(static_cast(idx++), x, y, z); + else + sink.pixel(static_cast(idx++), x, y, z); + } + } + } + } +}; + +} // namespace mm diff --git a/src/light/layouts/GridLayout.h b/src/light/layouts/GridLayout.h index d4f24890..e9082d90 100644 --- a/src/light/layouts/GridLayout.h +++ b/src/light/layouts/GridLayout.h @@ -1,6 +1,6 @@ #pragma once -#include "light/layouts/Layout.h" // umbrella: LayoutBase + light_types + math8 + cmath/cstdint/limits/numbers +#include "light/layouts/LayoutBase.h" namespace mm { @@ -35,7 +35,7 @@ class GridLayout : public LayoutBase { return static_cast(n > kMax ? kMax : n); } - void forEachCoord(CoordCallback cb, void* ctx) const override { + void forEachCoord(const CoordSink& sink) const override { // Use uint32_t for idx so it never wraps on uint16_t nrOfLightsType // (e.g. no-PSRAM ESP32 where 512×512 > 65535). Stop at the clamped // lightCount() so emitted indices stay within the allocated buffer. @@ -50,7 +50,7 @@ class GridLayout : public LayoutBase { const bool reverse = serpentine && (y & 1); for (lengthType i = 0; i < width && idx < limit; i++) { const lengthType x = reverse ? static_cast(width - 1 - i) : i; - cb(ctx, static_cast(idx++), x, y, z); + sink.pixel(static_cast(idx++), x, y, z); } } } diff --git a/src/light/layouts/HumanSizedCubeLayout.h b/src/light/layouts/HumanSizedCubeLayout.h index 21368ba0..23d66f60 100644 --- a/src/light/layouts/HumanSizedCubeLayout.h +++ b/src/light/layouts/HumanSizedCubeLayout.h @@ -1,6 +1,6 @@ #pragma once -#include "light/layouts/Layout.h" // umbrella: LayoutBase + light_types + math8 + cmath/cstdint/limits/numbers +#include "light/layouts/LayoutBase.h" namespace mm { @@ -49,7 +49,7 @@ class HumanSizedCubeLayout : public LayoutBase { return static_cast(n > kMax ? kMax : n); } - void forEachCoord(CoordCallback cb, void* ctx) const override { + void forEachCoord(const CoordSink& sink) const override { const uint32_t limit = lightCount(); uint32_t idx = 0; @@ -60,20 +60,20 @@ class HumanSizedCubeLayout : public LayoutBase { // front: z = 0 — for x { for y } for (lengthType x = 0; x < width && idx < limit; x++) for (lengthType y = 0; y < height && idx < limit; y++) - cb(ctx, static_cast(idx++), + sink.pixel(static_cast(idx++), static_cast(x + 1), static_cast(y + 1), 0); // back: z = depth+1 — for x { for y } for (lengthType x = 0; x < width && idx < limit; x++) for (lengthType y = 0; y < height && idx < limit; y++) - cb(ctx, static_cast(idx++), + sink.pixel(static_cast(idx++), static_cast(x + 1), static_cast(y + 1), static_cast(depth + 1)); // above: y = 0 — for x { for z } for (lengthType x = 0; x < width && idx < limit; x++) for (lengthType z = 0; z < depth && idx < limit; z++) - cb(ctx, static_cast(idx++), + sink.pixel(static_cast(idx++), static_cast(x + 1), 0, static_cast(z + 1)); // below (y = height+1) is intentionally omitted — commented out in the @@ -82,13 +82,13 @@ class HumanSizedCubeLayout : public LayoutBase { // left: x = 0 — for z { for y } for (lengthType z = 0; z < depth && idx < limit; z++) for (lengthType y = 0; y < height && idx < limit; y++) - cb(ctx, static_cast(idx++), + sink.pixel(static_cast(idx++), 0, static_cast(y + 1), static_cast(z + 1)); // right: x = width+1 — for z { for y } for (lengthType z = 0; z < depth && idx < limit; z++) for (lengthType y = 0; y < height && idx < limit; y++) - cb(ctx, static_cast(idx++), + sink.pixel(static_cast(idx++), static_cast(width + 1), static_cast(y + 1), static_cast(z + 1)); } diff --git a/src/light/layouts/Layout.h b/src/light/layouts/Layout.h deleted file mode 100644 index fe6e86c6..00000000 --- a/src/light/layouts/Layout.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -// Umbrella header for writing a layout: include this one file and you have the -// base class plus the maths helpers a layout commonly needs to place lights in -// space. A new layout is -// -// #pragma once -// #include "light/layouts/Layout.h" -// namespace mm { -// class MyLayout : public LayoutBase { ... }; -// } -// -// A layout overrides lightCount() and forEachCoord() (reporting each light's -// (x,y,z)); LayoutBase brings the base + light_types. The set is the whole surface -// a layout commonly reaches for — the integer + float trig and the small standard -// helpers coordinate placement uses — so a layout is one include. Unused -// declarations cost zero firmware bytes. A layout needing something genuinely -// outside this surface adds that one extra include. - -#include "light/layouts/LayoutBase.h" // LayoutBase + lengthType/nrOfLightsType/Dim (via light_types) -#include "light/light_types.h" // lengthType, nrOfLightsType, Coord3D (also via LayoutBase) -#include "core/math8.h" // sin8/cos8/atan2_8 — integer trig for circular/wheel layouts - -#include // sinf/cosf/fmodf — float trig where a layout needs it -#include // fixed-width ints -#include // std::numeric_limits — the lightCount clamp GridLayout uses -#include // std::numbers::pi_v — portable pi (ring/circle layouts) diff --git a/src/light/layouts/LayoutBase.h b/src/light/layouts/LayoutBase.h index 92d2a7f6..fab12eb2 100644 --- a/src/light/layouts/LayoutBase.h +++ b/src/light/layouts/LayoutBase.h @@ -1,15 +1,64 @@ #pragma once +// Include this one file to write a layout: it brings LayoutBase plus the maths helpers a layout commonly +// uses to place lights in space, so a layout is a single include: +// +// #pragma once +// #include "light/layouts/LayoutBase.h" +// namespace mm { +// class MyLayout : public LayoutBase { ... }; +// } +// +// A layout overrides lightCount() and forEachCoord() (reporting each light's (x,y,z)). The helper set +// below is the whole surface a layout commonly reaches for — the integer + float trig and the small +// standard helpers coordinate placement uses. Unused declarations cost zero firmware bytes; a layout +// needing something outside this surface adds that one extra include. + #include "core/MoonModule.h" -#include "light/light_types.h" // lengthType, nrOfLightsType +#include "light/light_types.h" // lengthType, nrOfLightsType, Coord3D +#include "core/math8.h" // sin8/cos8/atan2_8 — integer trig for circular/wheel layouts + +#include // sinf/cosf/fmodf — float trig where a layout needs it +#include // fixed-width ints +#include // std::numeric_limits — the lightCount clamp GridLayout uses +#include // std::numbers::pi_v — portable pi (ring/circle layouts) namespace mm { -/// Callback for layout coordinate iteration — a layout walks its positions and -/// invokes this per light with the physical index and (x,y,z). Owned by -/// LayoutBase: it's the signature of forEachCoord, which every layout overrides. +/// Callback for layout coordinate iteration — invoked per light with the physical +/// index and (x,y,z). The underlying function-pointer form a `CoordSink` carries. using CoordCallback = void(*)(void* ctx, nrOfLightsType idx, lengthType x, lengthType y, lengthType z); +/// The sink a layout emits its positions into — a builder with one method per KIND +/// of pixel, not a coordinate plus a boolean flag. `pixel()` is a normal light that +/// maps to a logical cell; `blackPixel()` is a GAP: a physical wire slot the driver +/// still clocks (so data flows through it on a continuous strand) that stays black, +/// mapping to no logical light. A layout with no dark regions only ever calls +/// `pixel()` and stays unaware `blackPixel()` exists; the black/lit choice is made +/// once, at the emit site, where the layout already knows the position. +/// +/// This is the builder/visitor-sink shape — named emit methods per variant, as in +/// Skia's `SkPath` (`moveTo`/`lineTo`/`close`) or a SAX/serde visitor — chosen over +/// a `bool black` parameter (the "boolean-trap" a named method avoids). The two +/// function pointers are the consumer's wiring: `cb` receives lit pixels; `blackCb` +/// receives gaps, and is null for a consumer that doesn't distinguish them (a plain +/// bounding-box or count walk), in which case a gap falls back to a normal pixel. +struct CoordSink { + CoordCallback cb; // lit pixel handler (required) + CoordCallback blackCb; // gap handler; null → gaps fall back to `cb` + void* ctx; + + /// A normal light at physical index `idx`, position (x,y,z). What every layout calls. + void pixel(nrOfLightsType idx, lengthType x, lengthType y, lengthType z) const { + cb(ctx, idx, x, y, z); + } + /// A GAP light: a physical slot that stays black. Only a layout with dark regions + /// calls this. Falls back to `pixel` when the consumer left `blackCb` null. + void blackPixel(nrOfLightsType idx, lengthType x, lengthType y, lengthType z) const { + (blackCb ? blackCb : cb)(ctx, idx, x, y, z); + } +}; + /// Base for one layout child of the `Layouts` container. A concrete layout /// (grid, sphere shell, …) implements `lightCount` and `forEachCoord` directly — /// no wrapper. Every layout control changes the physical light count, so any @@ -18,7 +67,12 @@ class LayoutBase : public MoonModule { public: ModuleRole role() const override { return ModuleRole::Layout; } virtual nrOfLightsType lightCount() const = 0; - virtual void forEachCoord(CoordCallback cb, void* ctx) const = 0; + virtual void forEachCoord(const CoordSink& sink) const = 0; + + /// Whether this layout emits any GAP (black) pixels — physical wire slots held dark. Default + /// false: a layout with no dark regions never overrides this and stays unaware gaps exist. Gates + /// the Layer's dense-identity fast path (which would light a gap) off when true. See CoordSink. + virtual bool hasBlackPixels() const { return false; } /// Every layout control (grid width/height/depth, …) changes the physical light /// count and therefore needs the pipeline-wide rebuild. See MoonModule::onControlChanged. diff --git a/src/light/layouts/Layouts.h b/src/light/layouts/Layouts.h index 96fc07fc..2bc5473d 100644 --- a/src/light/layouts/Layouts.h +++ b/src/light/layouts/Layouts.h @@ -46,27 +46,45 @@ class Layouts : public MoonModule { return total; } - void forEachCoord(CoordCallback cb, void* ctx) const { + void forEachCoord(const CoordSink& sink) const { if (!enabled()) return; nrOfLightsType offset = 0; for (uint8_t i = 0; i < childCount(); i++) { if (!child(i)->enabled()) continue; auto* layout = static_cast(child(i)); - // Wrap callback to add physical index offset + // Wrap the sink to add this child's physical index offset, so children stitch into one + // flat address space. Both kinds pass through their own offsetting relay — a gap in a + // child stays a gap in the container's stream (its wire slot just shifts by the offset). struct WrapCtx { - CoordCallback cb; - void* ctx; + const CoordSink* sink; nrOfLightsType offset; }; - WrapCtx wctx{cb, ctx, offset}; - layout->forEachCoord([](void* wc, nrOfLightsType idx, lengthType x, lengthType y, lengthType z) { - auto* w = static_cast(wc); - w->cb(w->ctx, idx + w->offset, x, y, z); - }, &wctx); + WrapCtx wctx{&sink, offset}; + layout->forEachCoord(CoordSink{ + [](void* wc, nrOfLightsType idx, lengthType x, lengthType y, lengthType z) { + auto* w = static_cast(wc); + w->sink->pixel(idx + w->offset, x, y, z); + }, + [](void* wc, nrOfLightsType idx, lengthType x, lengthType y, lengthType z) { + auto* w = static_cast(wc); + w->sink->blackPixel(idx + w->offset, x, y, z); + }, + &wctx}); offset += layout->lightCount(); } } + /// Whether any enabled child declares dark gaps (black pixels). Gates the Layer's dense-identity + /// fast path off: a gap needs the folded LUT (which drops the gap slot from the mapping), so an + /// identity map — which would light the gap — must not be used when this is true. + bool hasBlackPixels() const { + if (!enabled()) return false; + for (uint8_t i = 0; i < childCount(); i++) { + if (child(i)->enabled() && static_cast(child(i))->hasBlackPixels()) return true; + } + return false; + } + /// Status line: total physical lights + the physical bounding box (the extent /// of all light coordinates). Both are derived facts the container owns — the /// count is the driver buffer size, the box is the dense render extent. Shown @@ -77,13 +95,15 @@ class Layouts : public MoonModule { const nrOfLightsType lights = totalLightCount(); // One forEachCoord pass for the bounding box: max coordinate + 1 per axis. struct Extent { lengthType x, y, z; bool any; } e{0, 0, 0, false}; - forEachCoord([](void* ctx, nrOfLightsType, lengthType x, lengthType y, lengthType z) { + // Gaps count toward the physical box (a black pixel is a real position at (x,y,z)), so the + // extent walk uses one callback for both kinds — blackCb null → blackPixel falls back to it. + forEachCoord(CoordSink{[](void* ctx, nrOfLightsType, lengthType x, lengthType y, lengthType z) { auto* ex = static_cast(ctx); if (x > ex->x) ex->x = x; if (y > ex->y) ex->y = y; if (z > ex->z) ex->z = z; ex->any = true; - }, &e); + }, nullptr, &e}); const lengthType w = e.any ? e.x + 1 : 0; const lengthType h = e.any ? e.y + 1 : 0; const lengthType d = e.any ? e.z + 1 : 0; diff --git a/src/light/layouts/PanelLayout.h b/src/light/layouts/PanelLayout.h index d3d9db38..71beab68 100644 --- a/src/light/layouts/PanelLayout.h +++ b/src/light/layouts/PanelLayout.h @@ -1,6 +1,6 @@ #pragma once -#include "light/layouts/Layout.h" // umbrella: LayoutBase + light_types + math8 + cmath/cstdint/limits/numbers +#include "light/layouts/LayoutBase.h" namespace mm { @@ -62,7 +62,7 @@ class PanelLayout : public LayoutBase { return static_cast(n > kMax ? kMax : n); } - void forEachCoord(CoordCallback cb, void* ctx) const override { + void forEachCoord(const CoordSink& sink) const override { // MoonLight: axes = axisOrders[wiringOrder]; XY(0) = {1,0} (Y outer, X inner), // YX(1) = {0,1} (X outer, Y inner). axes[0] is the outer axis, axes[1] the inner. const uint8_t axisOrders[2][2] = { @@ -121,7 +121,7 @@ class PanelLayout : public LayoutBase { coord[outerAxis] = outerVal; coord[innerAxis] = innerVal; - cb(ctx, static_cast(idx++), coord[0], coord[1], 0); + sink.pixel(static_cast(idx++), coord[0], coord[1], 0); } } } diff --git a/src/light/layouts/PanelsLayout.h b/src/light/layouts/PanelsLayout.h index 84f8521d..c4c7fe7a 100644 --- a/src/light/layouts/PanelsLayout.h +++ b/src/light/layouts/PanelsLayout.h @@ -1,6 +1,6 @@ #pragma once -#include "light/layouts/Layout.h" // umbrella: LayoutBase + light_types + math8 + cmath/cstdint/limits/numbers +#include "light/layouts/LayoutBase.h" namespace mm { @@ -78,7 +78,7 @@ class PanelsLayout : public LayoutBase { return static_cast(n > kMax ? kMax : n); } - void forEachCoord(CoordCallback cb, void* ctx) const override { + void forEachCoord(const CoordSink& sink) const override { // MoonLight: axes = axisOrders[wiringOrder]; XY(0) = {1,0} (Y outer, X inner), // YX(1) = {0,1} (X outer, Y inner). axes[0] is the outer axis, axes[1] the inner. // The SAME table drives both the panel-grid walk and the per-panel walk. @@ -88,7 +88,7 @@ class PanelsLayout : public LayoutBase { }; const uint32_t limit = lightCount(); - Emit e{cb, ctx, panelWidth, panelHeight, limit, 0}; + Emit e{sink.cb, sink.ctx, panelWidth, panelHeight, limit, 0}; // ---- Outer walk: the panel grid. ---- // MoonLight: panels.iterate(0,0,a){ panels.iterate(1,a,b){ coordsP[axes[0]]=a; coordsP[axes[1]]=b; ... } } diff --git a/src/light/layouts/RingLayout.h b/src/light/layouts/RingLayout.h index 42ac6b41..df17fd9c 100644 --- a/src/light/layouts/RingLayout.h +++ b/src/light/layouts/RingLayout.h @@ -1,6 +1,6 @@ #pragma once -#include "light/layouts/Layout.h" // umbrella: LayoutBase + light_types + math8 + cmath/cstdint/limits/numbers +#include "light/layouts/LayoutBase.h" namespace mm { @@ -46,8 +46,8 @@ class RingLayout : public LayoutBase { return n; } - void forEachCoord(CoordCallback cb, void* ctx) const override { - walk(cb, ctx, nullptr); + void forEachCoord(const CoordSink& sink) const override { + walk(sink.cb, sink.ctx, nullptr); } private: diff --git a/src/light/layouts/Rings241Layout.h b/src/light/layouts/Rings241Layout.h index 8859ca3c..984e989f 100644 --- a/src/light/layouts/Rings241Layout.h +++ b/src/light/layouts/Rings241Layout.h @@ -1,6 +1,6 @@ #pragma once -#include "light/layouts/Layout.h" // umbrella: LayoutBase + light_types + math8 + cmath/cstdint/limits/numbers +#include "light/layouts/LayoutBase.h" namespace mm { @@ -47,7 +47,7 @@ class Rings241Layout : public LayoutBase { return total; // 1+8+12+16+24+32+40+48+60 = 241 } - void forEachCoord(CoordCallback cb, void* ctx) const override { + void forEachCoord(const CoordSink& sink) const override { // Shared centre — MoonLight: leftMargin = 1.1 * getRadius(60), assigned to // a uint8_t (implicit truncation), stored as ringCenter's integer x/y, then // scaled per LED: x = scale * ringCenter.x. @@ -71,7 +71,7 @@ class Rings241Layout : public LayoutBase { } // MoonLight truncates each axis with a (int) cast (toward zero); // z is scale * ringCenter.z = scale * 0 = 0. Preserve exactly. - cb(ctx, idx++, + sink.pixel(idx++, static_cast(static_cast(x)), static_cast(static_cast(y)), 0); diff --git a/src/light/layouts/SingleColumnLayout.h b/src/light/layouts/SingleColumnLayout.h index bb471b63..d5499f55 100644 --- a/src/light/layouts/SingleColumnLayout.h +++ b/src/light/layouts/SingleColumnLayout.h @@ -1,6 +1,6 @@ #pragma once -#include "light/layouts/Layout.h" // umbrella: LayoutBase + light_types + math8 + cmath/cstdint/limits/numbers +#include "light/layouts/LayoutBase.h" // Prior art: MoonLight SingleColumnLayout (MoonModules/MoonLight, layout nodes). // A vertical line of lights at a fixed x, running along y. Geometry reproduced @@ -35,7 +35,7 @@ class SingleColumnLayout : public LayoutBase { return static_cast(n > kMax ? kMax : n); } - void forEachCoord(CoordCallback cb, void* ctx) const override { + void forEachCoord(const CoordSink& sink) const override { // Emit the column in wiring order. The COORDINATE is (xposition, y, 0); // reversed_order walks y from the high end down, matching MoonLight's // addLight order. Stop at the clamped lightCount() so emitted indices @@ -46,12 +46,12 @@ class SingleColumnLayout : public LayoutBase { if (reversed_order) { for (int32_t y = static_cast(start_y) + static_cast(height) - 1; y >= static_cast(start_y) && idx < limit; y--) { - cb(ctx, static_cast(idx++), x, static_cast(y), 0); + sink.pixel(static_cast(idx++), x, static_cast(y), 0); } } else { for (int32_t y = static_cast(start_y); y < static_cast(start_y) + static_cast(height) && idx < limit; y++) { - cb(ctx, static_cast(idx++), x, static_cast(y), 0); + sink.pixel(static_cast(idx++), x, static_cast(y), 0); } } } diff --git a/src/light/layouts/SingleRowLayout.h b/src/light/layouts/SingleRowLayout.h index 58cb28af..ed4ee192 100644 --- a/src/light/layouts/SingleRowLayout.h +++ b/src/light/layouts/SingleRowLayout.h @@ -1,6 +1,6 @@ #pragma once -#include "light/layouts/Layout.h" // umbrella: LayoutBase + light_types + math8 + cmath/cstdint/limits/numbers +#include "light/layouts/LayoutBase.h" namespace mm { @@ -43,7 +43,7 @@ class SingleRowLayout : public LayoutBase { return static_cast(width); } - void forEachCoord(CoordCallback cb, void* ctx) const override { + void forEachCoord(const CoordSink& sink) const override { // The coordinate at index i is fixed at row yPosition, z=0; only the x walk // direction depends on reversedOrder — the two branches mirror MoonLight's // onLayout() forward/reverse loops exactly. @@ -52,12 +52,12 @@ class SingleRowLayout : public LayoutBase { if (reversedOrder) { for (int32_t x = static_cast(startX) + width - 1; x >= static_cast(startX); x--) { - cb(ctx, idx++, static_cast(x), y, 0); + sink.pixel(idx++, static_cast(x), y, 0); } } else { for (int32_t x = static_cast(startX); x < static_cast(startX) + width; x++) { - cb(ctx, idx++, static_cast(x), y, 0); + sink.pixel(idx++, static_cast(x), y, 0); } } } diff --git a/src/light/layouts/SphereLayout.h b/src/light/layouts/SphereLayout.h index 1d59ebe6..e3dc14f6 100644 --- a/src/light/layouts/SphereLayout.h +++ b/src/light/layouts/SphereLayout.h @@ -1,6 +1,6 @@ #pragma once -#include "light/layouts/Layout.h" // umbrella: LayoutBase + light_types + math8 + cmath/cstdint/limits/numbers +#include "light/layouts/LayoutBase.h" namespace mm { @@ -37,8 +37,8 @@ class SphereLayout : public LayoutBase { return n; } - void forEachCoord(CoordCallback cb, void* ctx) const override { - forEachShellPoint(cb, ctx, nullptr); + void forEachCoord(const CoordSink& sink) const override { + forEachShellPoint(sink.cb, sink.ctx, nullptr); } private: diff --git a/src/light/layouts/SpiralLayout.h b/src/light/layouts/SpiralLayout.h index 1f0f9ed4..719ddc80 100644 --- a/src/light/layouts/SpiralLayout.h +++ b/src/light/layouts/SpiralLayout.h @@ -1,6 +1,6 @@ #pragma once -#include "light/layouts/Layout.h" // umbrella: LayoutBase + light_types + math8 + cmath/cstdint/limits/numbers +#include "light/layouts/LayoutBase.h" namespace mm { @@ -42,7 +42,7 @@ class SpiralLayout : public LayoutBase { return static_cast(n > kMax ? kMax : n); } - void forEachCoord(CoordCallback cb, void* ctx) const override { + void forEachCoord(const CoordSink& sink) const override { const uint32_t limit = lightCount(); if (limit == 0) return; @@ -76,7 +76,7 @@ class SpiralLayout : public LayoutBase { const float y = currentHeight; const float z = currentRadius * cosf(radians); - cb(ctx, static_cast(i), + sink.pixel(static_cast(i), static_cast(x + middleX), static_cast(y), static_cast(z + middleZ)); diff --git a/src/light/layouts/TorontoBarGourdsLayout.h b/src/light/layouts/TorontoBarGourdsLayout.h index 07f88792..b1842852 100644 --- a/src/light/layouts/TorontoBarGourdsLayout.h +++ b/src/light/layouts/TorontoBarGourdsLayout.h @@ -1,6 +1,6 @@ #pragma once -#include "light/layouts/Layout.h" // umbrella: LayoutBase + light_types + math8 + cmath/cstdint/limits/numbers +#include "light/layouts/LayoutBase.h" namespace mm { @@ -53,8 +53,8 @@ class TorontoBarGourdsLayout : public LayoutBase { return n; } - void forEachCoord(CoordCallback cb, void* ctx) const override { - walk(cb, ctx, nullptr); + void forEachCoord(const CoordSink& sink) const override { + walk(sink.cb, sink.ctx, nullptr); } private: diff --git a/src/light/layouts/TubesLayout.h b/src/light/layouts/TubesLayout.h index 9f350447..7bb5e15e 100644 --- a/src/light/layouts/TubesLayout.h +++ b/src/light/layouts/TubesLayout.h @@ -1,6 +1,6 @@ #pragma once -#include "light/layouts/Layout.h" // umbrella: LayoutBase + light_types + math8 + cmath/cstdint/limits/numbers +#include "light/layouts/LayoutBase.h" namespace mm { @@ -46,7 +46,7 @@ class TubesLayout : public LayoutBase { return static_cast(n > kMax ? kMax : n); } - void forEachCoord(CoordCallback cb, void* ctx) const override { + void forEachCoord(const CoordSink& sink) const override { // uint32_t idx so it never wraps on a uint16_t nrOfLightsType; stop at the // clamped lightCount() so emitted indices stay within the buffer. const uint32_t limit = lightCount(); @@ -60,7 +60,7 @@ class TubesLayout : public LayoutBase { const lengthType y = reversed ? static_cast(ledsPerTube - 1 - i) : i; - cb(ctx, static_cast(idx++), x, y, 0); + sink.pixel(static_cast(idx++), x, y, 0); } } } diff --git a/src/light/layouts/WheelLayout.h b/src/light/layouts/WheelLayout.h index cb00b0dd..ee2bfc66 100644 --- a/src/light/layouts/WheelLayout.h +++ b/src/light/layouts/WheelLayout.h @@ -1,6 +1,6 @@ #pragma once -#include "light/layouts/Layout.h" // umbrella: LayoutBase + light_types + math8 + cmath/cstdint/limits/numbers +#include "light/layouts/LayoutBase.h" namespace mm { @@ -34,7 +34,7 @@ class WheelLayout : public LayoutBase { return static_cast(spokes) * static_cast(ledsPerSpoke); } - void forEachCoord(CoordCallback cb, void* ctx) const override { + void forEachCoord(const CoordSink& sink) const override { const int32_t maxR = ledsPerSpoke; // outermost radius (centre shift) nrOfLightsType idx = 0; for (uint16_t s = 0; s < spokes; s++) { @@ -47,7 +47,7 @@ class WheelLayout : public LayoutBase { // offset = radius * component / 128, then shift so coords are ≥ 0. const int32_t x = maxR + ((radius * cx) >> 7); const int32_t y = maxR + ((radius * sy) >> 7); - cb(ctx, idx, + sink.pixel(idx, static_cast(x), static_cast(y), 0); diff --git a/src/light/modifiers/BlockModifier.h b/src/light/modifiers/BlockModifier.h index 689b68ef..5142f56c 100644 --- a/src/light/modifiers/BlockModifier.h +++ b/src/light/modifiers/BlockModifier.h @@ -1,6 +1,6 @@ #pragma once -#include "light/modifiers/Modifier.h" // umbrella: ModifierBase + light_types + math8 + cmath/cstdint/cstdlib/algorithm +#include "light/modifiers/ModifierBase.h" namespace mm { diff --git a/src/light/modifiers/CheckerboardModifier.h b/src/light/modifiers/CheckerboardModifier.h index 2a5e3ba8..7aa8d185 100644 --- a/src/light/modifiers/CheckerboardModifier.h +++ b/src/light/modifiers/CheckerboardModifier.h @@ -1,6 +1,6 @@ #pragma once -#include "light/modifiers/Modifier.h" // umbrella: ModifierBase + light_types + math8 + cmath/cstdint/cstdlib/algorithm +#include "light/modifiers/ModifierBase.h" namespace mm { diff --git a/src/light/modifiers/CircleModifier.h b/src/light/modifiers/CircleModifier.h index 0edac1d1..d72d5efb 100644 --- a/src/light/modifiers/CircleModifier.h +++ b/src/light/modifiers/CircleModifier.h @@ -1,6 +1,6 @@ #pragma once -#include "light/modifiers/Modifier.h" // umbrella: ModifierBase + light_types + math8 + cmath/cstdint/cstdlib/algorithm +#include "light/modifiers/ModifierBase.h" namespace mm { diff --git a/src/light/modifiers/MirrorModifier.h b/src/light/modifiers/MirrorModifier.h index aaff3c17..1a002bdd 100644 --- a/src/light/modifiers/MirrorModifier.h +++ b/src/light/modifiers/MirrorModifier.h @@ -1,6 +1,6 @@ #pragma once -#include "light/modifiers/Modifier.h" // umbrella: ModifierBase + light_types + math8 + cmath/cstdint/cstdlib/algorithm +#include "light/modifiers/ModifierBase.h" namespace mm { diff --git a/src/light/modifiers/Modifier.h b/src/light/modifiers/Modifier.h deleted file mode 100644 index 3255c848..00000000 --- a/src/light/modifiers/Modifier.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -// Umbrella header for writing a modifier: include this one file and you have the -// base class plus the maths helpers a modifier commonly uses to fold coordinates. -// A new modifier is -// -// #pragma once -// #include "light/modifiers/Modifier.h" -// namespace mm { -// class MyModifier : public ModifierBase { ... }; -// } -// -// A modifier overrides one or more of modifyLogicalSize / modifyLogical / -// modifyLive to transform coordinates; ModifierBase brings the base + light_types. -// The set is the whole surface a modifier commonly reaches for — the integer trig, -// the float trig, and the small standard helpers coordinate folds use — so a -// modifier is one include. Unused declarations cost zero firmware bytes. A modifier -// needing something genuinely outside this surface adds that one extra include. - -#include "light/modifiers/ModifierBase.h" // ModifierBase + lengthType/nrOfLightsType/Dim (via light_types) -#include "core/math8.h" // sin8/cos8 — integer trig for a rotate/affine modifier - -#include // std::sqrt / sin / cos — float trig (circle/pinwheel folds) -#include // fixed-width ints -#include // std::abs -#include // std::max / std::min / std::clamp diff --git a/src/light/modifiers/ModifierBase.h b/src/light/modifiers/ModifierBase.h index d86b6ce6..1d1173c4 100644 --- a/src/light/modifiers/ModifierBase.h +++ b/src/light/modifiers/ModifierBase.h @@ -1,7 +1,27 @@ #pragma once +// Include this one file to write a modifier: it brings ModifierBase plus the maths helpers a coordinate +// fold commonly reaches for, so a modifier is a single include: +// +// #pragma once +// #include "light/modifiers/ModifierBase.h" +// namespace mm { +// class MyModifier : public ModifierBase { ... }; +// } +// +// A modifier overrides one or more of modifyLogicalSize / modifyLogical / modifyLive to transform +// coordinates. The helper set below is the whole surface a modifier commonly uses — the integer trig, the +// float trig, and the small standard helpers coordinate folds use. Unused declarations cost zero firmware +// bytes; a modifier needing something outside this surface adds that one extra include. + #include "core/MoonModule.h" #include "light/light_types.h" // lengthType, nrOfLightsType, Dim +#include "core/math8.h" // sin8/cos8 — integer trig for a rotate/affine modifier + +#include // std::sqrt / sin / cos — float trig (circle/pinwheel folds) +#include // fixed-width ints +#include // std::abs +#include // std::max / std::min / std::clamp namespace mm { diff --git a/src/light/modifiers/MultiplyModifier.h b/src/light/modifiers/MultiplyModifier.h index 78de4f8b..792a33ea 100644 --- a/src/light/modifiers/MultiplyModifier.h +++ b/src/light/modifiers/MultiplyModifier.h @@ -1,6 +1,6 @@ #pragma once -#include "light/modifiers/Modifier.h" // umbrella: ModifierBase + light_types + math8 + cmath/cstdint/cstdlib/algorithm +#include "light/modifiers/ModifierBase.h" namespace mm { diff --git a/src/light/modifiers/PinwheelModifier.h b/src/light/modifiers/PinwheelModifier.h index fc33ce59..c72c9b8d 100644 --- a/src/light/modifiers/PinwheelModifier.h +++ b/src/light/modifiers/PinwheelModifier.h @@ -1,6 +1,6 @@ #pragma once -#include "light/modifiers/Modifier.h" // umbrella: ModifierBase + light_types + math8 + cmath/cstdint/cstdlib/algorithm +#include "light/modifiers/ModifierBase.h" namespace mm { diff --git a/src/light/modifiers/RandomMapModifier.h b/src/light/modifiers/RandomMapModifier.h index 675a9e6a..0db3eb73 100644 --- a/src/light/modifiers/RandomMapModifier.h +++ b/src/light/modifiers/RandomMapModifier.h @@ -1,6 +1,6 @@ #pragma once -#include "light/modifiers/Modifier.h" // umbrella: ModifierBase + light_types + math8 + cmath/cstdint/cstdlib/algorithm +#include "light/modifiers/ModifierBase.h" #include "platform/platform.h" // alloc / free / millis diff --git a/src/light/modifiers/RegionModifier.h b/src/light/modifiers/RegionModifier.h index c7e9a4be..08697568 100644 --- a/src/light/modifiers/RegionModifier.h +++ b/src/light/modifiers/RegionModifier.h @@ -1,6 +1,6 @@ #pragma once -#include "light/modifiers/Modifier.h" // umbrella: ModifierBase + light_types + math8 + cmath/cstdint/cstdlib/algorithm +#include "light/modifiers/ModifierBase.h" namespace mm { diff --git a/src/light/modifiers/RippleXZModifier.h b/src/light/modifiers/RippleXZModifier.h index b667e44e..13e89837 100644 --- a/src/light/modifiers/RippleXZModifier.h +++ b/src/light/modifiers/RippleXZModifier.h @@ -1,6 +1,6 @@ #pragma once -#include "light/modifiers/Modifier.h" // umbrella: ModifierBase + light_types + math8 + cmath/cstdint/cstdlib/algorithm +#include "light/modifiers/ModifierBase.h" namespace mm { diff --git a/src/light/modifiers/RotateModifier.h b/src/light/modifiers/RotateModifier.h index 1cbab2b9..81cd1129 100644 --- a/src/light/modifiers/RotateModifier.h +++ b/src/light/modifiers/RotateModifier.h @@ -1,6 +1,6 @@ #pragma once -#include "light/modifiers/Modifier.h" // umbrella: ModifierBase + light_types + math8 + cmath/cstdint/cstdlib/algorithm +#include "light/modifiers/ModifierBase.h" namespace mm { diff --git a/src/light/modifiers/TransposeModifier.h b/src/light/modifiers/TransposeModifier.h index 42e59396..3afeba55 100644 --- a/src/light/modifiers/TransposeModifier.h +++ b/src/light/modifiers/TransposeModifier.h @@ -1,6 +1,6 @@ #pragma once -#include "light/modifiers/Modifier.h" // umbrella: ModifierBase + light_types + math8 + cmath/cstdint/cstdlib/algorithm +#include "light/modifiers/ModifierBase.h" namespace mm { diff --git a/src/main.cpp b/src/main.cpp index a815ce0a..78ee601b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,7 @@ #include "core/Scheduler.h" #include "light/layers/Layers.h" #include "light/layouts/GridLayout.h" +#include "light/layouts/GridBlacksLayout.h" #include "light/layouts/SphereLayout.h" #include "light/layouts/WheelLayout.h" #include "light/layouts/SingleRowLayout.h" @@ -140,6 +141,7 @@ static void registerModuleTypes() { mm::ModuleFactory::registerType("PanelsLayout", "light/layouts.md#panels"); mm::ModuleFactory::registerType("TorontoBarGourdsLayout", "light/layouts.md#torontobargourds"); mm::ModuleFactory::registerType("GridLayout", "light/layouts.md#grid"); + mm::ModuleFactory::registerType("GridBlacksLayout", "light/layouts.md#gridblacks"); mm::ModuleFactory::registerType("PanelLayout", "light/layouts.md#panel"); mm::ModuleFactory::registerType("RingLayout", "light/layouts.md#ring"); mm::ModuleFactory::registerType("Rings241Layout", "light/layouts.md#rings241"); diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 40f519e4..35340d01 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -297,8 +297,9 @@ void stopPinnedTask(WorkerTask& t) { t.impl = nullptr; } -void taskWdtSubscribe() {} // no watchdog on the host -void taskWdtReset() {} // no watchdog on the host +void taskWdtSubscribe() {} // no watchdog on the host +void taskWdtUnsubscribe() {} // no watchdog on the host +void taskWdtReset() {} // no watchdog on the host // A host build has no real GPIOs to protect — every pin is valid, output-capable, and free of @@ -978,8 +979,13 @@ int TcpConnection::read(uint8_t* buf, size_t maxLen) { bool TcpConnection::write(const uint8_t* data, size_t len) { if (fd_ < 0) return false; - // Send ALL bytes (blocking retry on a full buffer) — an HTTP response must arrive complete. - // See the ESP32 impl: a healthy interface drains in microseconds so the retry rarely spins. + // Send ALL bytes (blocking retry on a full buffer) — an HTTP response / WS frame must arrive complete. + // A healthy interface drains in microseconds so the retry rarely spins. Bounded by a wall-clock + // deadline (mirrors the ESP32 impl): this runs on the render thread, and a stalled peer whose TCP + // receive window is full would otherwise make send() block forever and hang the loop. On timeout, + // return false so the caller closes that client instead of wedging the device. + constexpr uint32_t kWriteDeadlineMs = 2000; + const uint32_t start = millis(); size_t sent = 0; while (sent < len) { auto n = ::send(sock(fd_), reinterpret_cast(data + sent), @@ -987,6 +993,7 @@ bool TcpConnection::write(const uint8_t* data, size_t len) { if (n > 0) { sent += static_cast(n); } else if (sockWouldBlock()) { + if (millis() - start >= kWriteDeadlineMs) return false; // stalled peer — don't hang the loop std::this_thread::sleep_for(std::chrono::milliseconds(1)); #ifndef _WIN32 } else if (errno == EINTR) { diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp index 4a615344..74f038a0 100644 --- a/src/platform/esp32/platform_esp32.cpp +++ b/src/platform/esp32/platform_esp32.cpp @@ -1495,15 +1495,23 @@ int TcpConnection::read(uint8_t* buf, size_t maxLen) { bool TcpConnection::write(const uint8_t* data, size_t len) { if (fd_ < 0) return false; - // Send every byte, retrying on a full send buffer — an HTTP response must arrive complete. - // Blocks the caller until the peer drains, which suits a one-shot response on a per-request - // connection; a healthy interface drains in microseconds, so the retry rarely spins. + // Send every byte, retrying on a full send buffer — a response/frame must arrive complete. A healthy + // interface drains in microseconds, so the retry rarely spins. BUT this runs on the render thread (WS + // frames via sendWsTextFrame, HTTP responses via handleConnection), and a stalled peer (a slow or + // half-open client whose TCP receive window is full) makes lwip_write return EWOULDBLOCK indefinitely. + // An UNBOUNDED retry would then hang the render loop until the Task-WDT (12 s) panic-reboots the whole + // device — observed as a WS client connect making the board reboot every few seconds. So bound the wait + // by a wall-clock deadline well above a healthy drain (µs) and well below the WDT: on timeout, return + // false so the caller closes that client (the browser reconnects) instead of taking the device down. + constexpr uint32_t kWriteDeadlineMs = 2000; + const uint32_t start = millis(); size_t sent = 0; while (sent < len) { auto n = lwip_write(fd_, data + sent, len - sent); if (n > 0) { sent += static_cast(n); } else if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + if (millis() - start >= kWriteDeadlineMs) return false; // stalled peer — don't hang the render loop vTaskDelay(pdMS_TO_TICKS(1)); // wait for send buffer space } else { return false; // real error @@ -1594,7 +1602,12 @@ bool TcpServer::open(uint16_t port) { return false; } - if (listen(fd_, 4) < 0) { + // Backlog sized for a browser page-load burst: a fresh load opens the HTML + several JS/CSS files + + // the WebSocket upgrade all at once (~8 parallel connections). With a small backlog the excess SYNs + // are dropped and the browser must retry — the "load it a few times before the UI shows / the socket + // connects" symptom. 8 covers a whole first-load burst so nothing is dropped. (lwIP caps this at + // CONFIG_LWIP_MAX_LISTENING_TCP; 8 is within the default 16.) + if (listen(fd_, 8) < 0) { lwip_close(fd_); fd_ = -1; return false; diff --git a/src/platform/esp32/platform_esp32_worker.cpp b/src/platform/esp32/platform_esp32_worker.cpp index 23811d52..eaef5980 100644 --- a/src/platform/esp32/platform_esp32_worker.cpp +++ b/src/platform/esp32/platform_esp32_worker.cpp @@ -10,7 +10,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" -#include "esp_task_wdt.h" // the render loop subscribes itself so a wedge self-heals (reboot) not hangs +#include "esp_task_wdt.h" // the render loop and the encode worker each subscribe themselves so a wedge self-heals (reboot) not hangs #include #include @@ -39,9 +39,11 @@ struct EspWorker { }; // Trampoline: run the caller's fn (which owns its own loop and returns when it observes the stop flag -// via a woken waitNotify), then self-delete the RTOS task. The worker isn't registered with the task -// watchdog — it blocks in waitNotify between frames (a natural yield), and each encode is one bounded -// frame, so it never trips the idle-task WDT the way a busy-spin would; taskWdtReset() is a no-op. +// via a woken waitNotify), then self-delete the RTOS task. The fn manages its own WDT membership: the +// encode worker subscribes itself via taskWdtSubscribe() at loop start, feeds it with taskWdtReset() each +// frame, and unsubscribes via taskWdtUnsubscribe() before returning (the subscription is per-task, so it +// can't ride the render task's — see taskWdtSubscribe). A fn that never subscribes leaves taskWdtReset() a +// no-op, so this trampoline stays generic. void workerTrampoline(void* arg) { auto* w = static_cast(arg); w->fn(w->user); // runs until stopPinnedTask flips w->stop and wakes it @@ -121,25 +123,36 @@ void stopPinnedTask(WorkerTask& t) { t.impl = nullptr; } -// Whether taskWdtSubscribe succeeded — so taskWdtReset only feeds a real subscription. Written once at -// render-loop start, read each tick on the same (render) task, so no synchronization is needed. -static bool s_renderWdtSubscribed = false; +// Whether the CALLING task subscribed to the WDT — so taskWdtReset only feeds a real subscription. The +// WDT subscription is per-task (esp_task_wdt_add/_reset act on the current task), so this flag is +// thread_local, NOT one global: the render loop AND the core-1 encode worker each feed the WDT, and a +// single global would let the worker call esp_task_wdt_reset() on a task the render loop subscribed — +// which the IDF rejects as "task not found", flooding the log every tick and starving the network stack. +// Each task reads/writes its own copy, on its own task, so no synchronization is needed. +static thread_local bool s_wdtSubscribed = false; -// Subscribe the CURRENT (render-loop) task to the task WDT. The sdkconfig runs the TWDT with idle-task -// checking OFF (a saturated core is healthy), so nothing is watched unless a task subscribes — this is -// that one subscription: if the render loop stops feeding the WDT (a genuine wedge, not a busy frame), -// it panics and reboots (the self-heal) instead of hanging silently, and leaves a backtrace. Idempotent -// enough for one caller; a failure (WDT not inited) just leaves s_renderWdtSubscribed false and reset a -// no-op, degrading to today's unwatched behavior rather than crashing. +// Subscribe the CURRENT task to the task WDT. The sdkconfig runs the TWDT with idle-task checking OFF (a +// saturated core is healthy), so nothing is watched unless a task subscribes. Both the render loop and the +// encode worker call this on their own task: if either stops feeding the WDT (a genuine wedge, not a busy +// frame), it panics and reboots (the self-heal) instead of hanging silently, and leaves a backtrace. +// Idempotent per task; a failure (WDT not inited) just leaves s_wdtSubscribed false and reset a no-op, +// degrading to unwatched behavior rather than crashing. void taskWdtSubscribe() { - if (s_renderWdtSubscribed) return; - if (esp_task_wdt_add(nullptr) == ESP_OK) s_renderWdtSubscribed = true; + if (s_wdtSubscribed) return; + if (esp_task_wdt_add(nullptr) == ESP_OK) s_wdtSubscribed = true; } -// Feed the render task's WDT subscription (esp_task_wdt_reset), called each render tick. No-op until/unless -// taskWdtSubscribe ran, so a build/config without the WDT (or a worker that never subscribed) is unaffected. +// Unsubscribe THIS task before it exits (esp_task_wdt_delete), so a torn-down task leaves no stale WDT +// entry the IDF would keep checking. No-op if this task never subscribed. +void taskWdtUnsubscribe() { + if (!s_wdtSubscribed) return; + if (esp_task_wdt_delete(nullptr) == ESP_OK) s_wdtSubscribed = false; +} + +// Feed THIS task's WDT subscription (esp_task_wdt_reset). No-op until/unless taskWdtSubscribe ran on this +// same task, so a task that never subscribed (or a build/config without the WDT) is unaffected. void taskWdtReset() { - if (s_renderWdtSubscribed) esp_task_wdt_reset(); + if (s_wdtSubscribed) esp_task_wdt_reset(); } } // namespace mm::platform diff --git a/src/platform/platform.h b/src/platform/platform.h index bd6ed421..a0c36a04 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -147,12 +147,18 @@ bool waitNotify(WorkerTask& t, uint32_t timeoutMs); // Signal stop + wake, then block until the worker fn has returned and the task is torn down. void stopPinnedTask(WorkerTask& t); // Subscribe THIS task to the task watchdog (esp_task_wdt_add), so a wedge on it panics-and-reboots (the -// self-heal) instead of hanging silently — called once by the render loop before it starts ticking. The -// sdkconfig has idle-task checking OFF (a saturated core is healthy, not a bug), so nothing is watched -// unless a task subscribes explicitly; this is that subscription. No-op on desktop. +// self-heal) instead of hanging silently. Called by each task that feeds the WDT before it starts ticking: +// the render loop, and the core-1 encode worker. The subscription is per-task (so is the feed and the flag +// behind it), which is why a task must subscribe itself rather than inherit another task's subscription. +// The sdkconfig has idle-task checking OFF (a saturated core is healthy, not a bug), so nothing is watched +// unless a task subscribes explicitly. No-op on desktop. void taskWdtSubscribe(); -// Reset THIS task's watchdog (esp_task_wdt_reset) — called each render tick to feed the subscription -// above. No-op on desktop, and no-op on ESP32 if the task never subscribed. +// Unsubscribe THIS task from the watchdog (esp_task_wdt_delete) before it exits, so a torn-down task (the +// encode worker when the render split disengages) leaves no dangling WDT entry. No-op on desktop, and +// no-op on ESP32 if the task never subscribed. +void taskWdtUnsubscribe(); +// Reset THIS task's watchdog (esp_task_wdt_reset) — called each tick to feed the subscription above. No-op +// on desktop, and no-op on ESP32 if the task never subscribed. void taskWdtReset(); // --- GPIO capability introspection (PinsModule) --------------------------------------------- diff --git a/src/ui/app.js b/src/ui/app.js index 0cc7a12b..e225fbf5 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -36,9 +36,14 @@ let state = null; let selectedModule = null; let availableTypes = []; // populated from GET /api/types after first connection let ws = null; -let wsRetryMs = 500; // exponential backoff: 500 → 1000 → 2000 → 4000 → 5000 +const WS_RETRY_MIN_MS = 200; // first reconnect is quick so a dropped initial connect (common on Safari, + // whose first attempt can lose a contended socket) comes back near-instantly +let wsRetryMs = WS_RETRY_MIN_MS; // exponential backoff: 200 → 400 → 800 → … → 5000 ceiling let wsHeartbeat = null; +let wsReconnectTimer = null; // the pending reconnect setTimeout — tracked so pagehide can cancel it let wsPaused = false; // gated by document.visibilityState +let wsUnloading = false; // true once the page starts unloading (refresh/navigate) — suppresses the + // reconnect on the closing socket so the departing page doesn't error-log const dragTimers = {}; // per-control debounce timers (clearTimeout handles) const dragTs = {}; // per-control last-touched timestamp (ms) — a short post-interaction cooldown @@ -55,11 +60,83 @@ const LS_SELECTED = "mm_selectedRoot"; const LS_THEME = "mm_theme"; const LS_TIMING = "mm_timing_mode"; const LS_TABS = "mm_selectedTabs"; // { [containerName]: childName } — the open tab per container +const LS_EXPANDED = "mm_expanded"; // [moduleName, …] — modules whose "controls"
is open +const LS_TA_SIZE = "mm_textareaSizes"; // { ":": heightPx } — user-dragged textarea heights +const LS_CARDS_W = "mm_cardsWidth"; // px — dragged width of the docked card column (right side) // The open tab per container, persisted so a reload doesn't dump you back on the first child. let selectedTabs = {}; try { selectedTabs = JSON.parse(lsRead(LS_TABS, "{}")) || {}; } catch { selectedTabs = {}; } +// Which modules have their "controls" disclosure open — VIEW-ONLY state the backend knows nothing about, so +// it lives here (like selectedTabs), NOT in the module state. Persisting it means a full-state rebuild (or a +// page reload) restores the open/closed expander instead of snapping it shut. Value/structure/picker state +// all come from the backend, so those need no client persistence. +let expandedSet = new Set(); +try { expandedSet = new Set(JSON.parse(lsRead(LS_EXPANDED, "[]")) || []); } catch { expandedSet = new Set(); } +function saveExpanded() { localStorage.setItem(LS_EXPANDED, JSON.stringify([...expandedSet])); } + +// The height a user dragged each textarea to — again VIEW-ONLY state the backend doesn't own (like the tab +// and expander state above), keyed by ":". Persisting it means a resized script/config box +// keeps its size across a full-state rebuild and a page reload instead of snapping back to the 2-row default. +let textareaSizes = {}; +try { textareaSizes = JSON.parse(lsRead(LS_TA_SIZE, "{}")) || {}; } catch { textareaSizes = {}; } +function saveTextareaSize(key, heightPx) { textareaSizes[key] = heightPx; localStorage.setItem(LS_TA_SIZE, JSON.stringify(textareaSizes)); } + +// The width the user dragged the docked card column to — VIEW-ONLY state (like the tab/expander/textarea +// state above). Applied as the --cards-width CSS custom property (the #main flex-basis reads it, clamped in +// CSS so it can't crowd out the preview or vanish); restored on boot. Only meaningful in docked mode, where +// the cards sit beside the preview; PiP/narrow mode makes them full-width and hides the handle. +function applyCardsWidth(px) { document.documentElement.style.setProperty("--cards-width", px + "px"); } +(function restoreCardsWidth() { + const w = parseInt(lsRead(LS_CARDS_W, ""), 10); + if (Number.isFinite(w) && w > 0) applyCardsWidth(w); +})(); + +// Wire the card-column resize handle (index.html #cards-resize). Dragging it left/right sets --cards-width +// live and persists the final value. The handle sits at the LEFT edge of #main, so dragging left widens the +// cards (they grow toward the preview); width = the pane's right edge minus the pointer x. Bounded by the +// same clamp the CSS enforces, and coalesced through requestAnimationFrame so a drag doesn't thrash layout. +function setupCardsResize() { + const handle = document.getElementById("cards-resize"); + const main = document.getElementById("main"); + if (!handle || !main) return; + const MIN = 280, MAX = 900; + let dragging = false, raf = 0, pendingW = 0; + const onMove = (clientX) => { + // #main's right edge is fixed (the workspace's right edge); width grows as the pointer moves left. + const right = main.getBoundingClientRect().right; + pendingW = Math.max(MIN, Math.min(MAX, Math.round(right - clientX))); + if (raf) return; + raf = requestAnimationFrame(() => { raf = 0; applyCardsWidth(pendingW); }); + }; + const stop = () => { + if (!dragging) return; + dragging = false; + handle.classList.remove("dragging"); + document.body.classList.remove("cards-resizing"); + if (pendingW > 0) localStorage.setItem(LS_CARDS_W, String(pendingW)); + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", stop); + window.removeEventListener("pointercancel", stop); + }; + const onPointerMove = (e) => { if (dragging) onMove(e.clientX); }; + handle.addEventListener("pointerdown", (e) => { + // Only resize in docked mode — the handle is CSS-hidden otherwise, but guard anyway. + if (!document.querySelector(".workspace")?.classList.contains("mode-docked")) return; + e.preventDefault(); + dragging = true; + pendingW = main.getBoundingClientRect().width; + handle.classList.add("dragging"); + document.body.classList.add("cards-resizing"); + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", stop); + window.addEventListener("pointercancel", stop); + }); + // Double-click resets to the default width (a common resize-handle affordance). + handle.addEventListener("dblclick", () => { applyCardsWidth(480); localStorage.setItem(LS_CARDS_W, "480"); }); +} + function lsRead(key, defaultVal) { const v = localStorage.getItem(key); return v !== null ? v : defaultVal; @@ -73,16 +150,19 @@ let theme = lsRead(LS_THEME, "dark"); // --------------------------------------------------------------------------- function connectWs() { + if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; } if (ws) { try { ws.close(); } catch {} ws = null; } const url = `ws://${location.host}/ws`; ws = new WebSocket(url); + const sock = ws; // captured so a stale socket's late callback (after a reconnect swapped `ws`) is a no-op ws.binaryType = "arraybuffer"; ws.onopen = () => { - wsRetryMs = 500; // reset backoff + if (sock !== ws) return; // a newer socket already took over — ignore this stale open + wsRetryMs = WS_RETRY_MIN_MS; // reset backoff setWsDot(true); // Keepalive ping every 25s — Safari kills idle WebSockets otherwise clearInterval(wsHeartbeat); @@ -92,7 +172,7 @@ function connectWs() { }; ws.onmessage = (e) => { - if (wsPaused) return; + if (sock !== ws || wsPaused) return; // ignore a stale socket's late frame if (e.data instanceof ArrayBuffer) { preview.onBinaryMessage(e.data); return; @@ -123,11 +203,13 @@ function connectWs() { }; ws.onclose = () => { - setWsDot(false); + if (sock !== ws) return; // a stale socket closing after we already moved on — leave the live one alone clearInterval(wsHeartbeat); wsHeartbeat = null; - // Exponential backoff with 5s ceiling - setTimeout(connectWs, wsRetryMs); + if (wsUnloading) return; // the page is going away — don't reconnect (and don't touch the DOM) + setWsDot(false); + // Exponential backoff with 5s ceiling; track the timer so pagehide can cancel a pending reconnect. + wsReconnectTimer = setTimeout(connectWs, wsRetryMs); wsRetryMs = Math.min(wsRetryMs * 2, 5000); }; @@ -148,9 +230,20 @@ window.addEventListener("pageshow", (e) => { if (e.persisted) { // Safari restored from bfcache: re-establish state wsPaused = false; + wsUnloading = false; // a bfcache-restored page is live again — allow reconnects if (!ws || ws.readyState !== WebSocket.OPEN) connectWs(); } }); +// Close the socket cleanly as the page unloads (a refresh, a navigation, or a bfcache suspend). Without +// this the departing page's socket is torn down abnormally by the browser, which logs a "connection lost" +// error to the console every refresh. Sending a normal (1000) close first, and marking wsUnloading so +// onclose skips its reconnect, makes the handover silent. pagehide (not beforeunload) fires for bfcache too. +window.addEventListener("pagehide", () => { + wsUnloading = true; + clearInterval(wsHeartbeat); + if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; } // no reconnect after unload + if (ws) { try { ws.close(1000); } catch { /* already closing */ } } +}); // --------------------------------------------------------------------------- // 3. REST helpers + module mutations @@ -160,29 +253,52 @@ async function init() { applyTheme(theme); setupStatusBarButtons(); setupUpdateBadge(); - try { - const resp = await fetch("/api/state"); - state = await resp.json(); - const savedSel = lsRead(LS_SELECTED, null); - if (state.modules && state.modules.length > 0) { - const exists = savedSel && state.modules.some(m => m.name === savedSel); - selectedModule = exists ? savedSel : state.modules[0].name; - } - renderNav(); - renderCards(); - updateStatusBar(); - // /api/types arrived in plan-11; fetch in parallel. When it arrives, re-render - // so reset-to-default buttons (whose defaults come from this payload) appear. - fetch("/api/types").then(r => r.json()).then(j => { - availableTypes = j.types || []; - if (state) renderCards(); - }).catch(() => {}); - } catch (err) { - document.getElementById("main").textContent = "Error: " + err.message; - } + setupCardsResize(); + // Open the WebSocket FIRST, before any HTTP fetch. The device pushes a full {modules} state on connect + // (handleWebSocketUpgrade → requestFullResync), so the WS is the primary state source — the /api/state + // fetch below is only a first-paint shortcut. Connecting first matters most on Safari: it opens more + // parallel connections up front and is quicker to give up on a contended one, so if the WS is opened + // LAST (after several awaited fetches + the page's file loads) it lands in the most-saturated moment of + // the device's small socket pool and Safari abandons it — the "basic UI shows but the WS never goes + // live" symptom. Opening it first lets it grab an uncontended slot; Chrome tolerated the old order, so + // this fixes Safari without regressing Chrome. connectWs(); preview.init(); preview.setupLayout(); + // First-paint shortcut: render from a one-shot /api/state so the cards appear immediately instead of + // waiting for the WS's first full-state push. The WS then keeps everything live. If this fetch fails + // (a contended slot), it's non-fatal — the WS full state fills in the moment it lands. Since the WS is + // opened FIRST, its full-state push can beat this await; when it has (state already set), DON'T let the + // REST snapshot overwrite the newer, live WS state — just skip the commit. + try { + const resp = await fetch("/api/state"); + if (resp.ok && (!state || !Array.isArray(state.modules))) { + const snap = await resp.json(); + if (snap && Array.isArray(snap.modules)) { + state = snap; + const savedSel = lsRead(LS_SELECTED, null); + if (state.modules.length > 0) { + const exists = savedSel && state.modules.some(m => m.name === savedSel); + selectedModule = exists ? savedSel : state.modules[0].name; + } + renderNav(); + renderCards(); + updateStatusBar(); + } + } + } catch { /* non-fatal — the WS full state renders the UI when it arrives */ } + // /api/types arrived in plan-11; fetch in parallel. When it arrives, the reset-to-default buttons (whose + // defaults come from this payload) need to appear — but a full renderCards() rebuilds the DOM, which + // would blow away a control the user is mid-edit (this fires ~1 s after first paint, so that's rare but + // possible). Skip the re-render while an editable field is focused / a native select is open; the reset + // buttons then appear on the next structural render instead of interrupting the edit. + fetch("/api/types").then(r => r.json()).then(j => { + availableTypes = j.types || []; + const el = document.activeElement; + const editing = el && (el.matches("input, textarea") || el.closest("select") + || document.querySelector('select[data-open="true"]')); + if (state && !editing) renderCards(); + }).catch(() => {}); } // The message for a failed fetch Response: the server's own `{"error": …}` body (JSON, e.g. @@ -588,12 +704,14 @@ function applyTabDot(tab, mod) { tab.appendChild(dot); } -// Patch-path twin of applyTabDot: the tab strip is built in renderCards(), which the WS value patch -// deliberately never re-runs — so without this a fault appearing on a background tab would stay -// invisible until the next full render. (The UI has two render paths; a rule must live in both.) +// Patch-path twin of applyTabDot + the disabled greying: the tab strip is built in renderCards(), which the +// WS value patch deliberately never re-runs — so without this, a fault (or an enable/disable) on a background +// tab would stay invisible until the next full render. (The UI has two render paths; a rule must live in both.) function updateTabDot(mod) { const tab = document.querySelector(`.tab[data-tab-mid="${cssEscape(mod.name)}"]`); - if (tab) applyTabDot(tab, mod); + if (!tab) return; + applyTabDot(tab, mod); + tab.classList.toggle("tab--disabled", mod.enabled === false); // grey a disabled module's tab title } // Tab strip + the one selected child. `active` falls back to the first child whenever the remembered @@ -609,7 +727,10 @@ function renderChildTabs(mod, childrenEl, depth) { for (const child of mod.children) { const tab = document.createElement("button"); - tab.className = "tab" + (child.name === active ? " tab-active" : ""); + // Grey a disabled child's tab title (mirrors the card's card--disabled), so it reads as inactive + // from the strip without opening it. Derived purely from child.enabled — no backend round-trip. + tab.className = "tab" + (child.name === active ? " tab-active" : "") + + (child.enabled === false ? " tab--disabled" : ""); tab.type = "button"; tab.setAttribute("role", "tab"); tab.setAttribute("aria-selected", child.name === active ? "true" : "false"); @@ -687,6 +808,12 @@ function createCard(mod, depth) { enabled.classList.toggle("module-enabled--off", !on); enabled.setAttribute("aria-pressed", on ? "true" : "false"); card.classList.toggle("card--disabled", !on); + // Grey this module's TAB in the same click, alongside its card — so the tab title dims INSTANTLY + // instead of waiting ~1s for the server's full-state round-trip. (updateTabDot still syncs it on the + // patch path, idempotently, so this just makes the on/off button the immediate driver.) The tab + // lives in the parent's strip, found by the same data-tab-mid updateTabDot uses. + const tabEl = document.querySelector(`.tab[data-tab-mid="${cssEscape(mod.name)}"]`); + if (tabEl) tabEl.classList.toggle("tab--disabled", !on); }; setEnabledUi(mod.enabled === undefined ? true : !!mod.enabled); enabled.addEventListener("click", () => { @@ -792,6 +919,14 @@ function createCard(mod, depth) { const controlsHost = wrapInDetails ? (() => { const d = document.createElement("details"); d.className = "card-controls-collapse"; + // Restore the open/closed state from localStorage, so a full-state rebuild (or a page reload) keeps + // the expander as the user left it instead of snapping shut. Persist it on toggle — same pattern as + // the selected tab (LS_TABS). VIEW-only state; nothing to do with the module's backend state. + d.open = expandedSet.has(mod.name); + d.addEventListener("toggle", () => { + if (d.open) expandedSet.add(mod.name); else expandedSet.delete(mod.name); + saveExpanded(); + }); const s = document.createElement("summary"); s.textContent = "controls"; d.appendChild(s); @@ -1412,6 +1547,23 @@ function createControl(moduleName, moduleType, ctrl) { input.dataset.key = ctrl.name; input.rows = 2; // default 2 lines; CSS height + resize grip control size input.spellcheck = false; + // Restore a previously dragged height (view-state, see textareaSizes). A stored px height + // overrides the 2-row default; an untouched textarea has no entry and keeps the default. + const savedH = textareaSizes[key]; + if (typeof savedH === "number" && savedH > 0) input.style.height = savedH + "px"; + // Persist the height whenever the user drags the resize grip. A