diff --git a/.claude/workflows/write-behaviour-tests.js b/.claude/workflows/write-behaviour-tests.js index efbac91e..9d2b8709 100644 --- a/.claude/workflows/write-behaviour-tests.js +++ b/.claude/workflows/write-behaviour-tests.js @@ -58,7 +58,7 @@ Repo: the current workspace root (the projectMM checkout you're running in) — ## Study first (do NOT guess behaviour) 1. Read the module header: ${header} — understand what it ACTUALLY does: its controls, its render/modify logic, what it writes to the buffer or how it transforms coordinates. Behaviour is the spec. -2. Read the module's spec entry if useful: docs/moonmodules/light/${kind === 'effect' ? 'effects/effects.md' : 'modifiers/modifiers.md'} (find the ${cls.replace(/Effect$|Modifier$/, '')} section). +2. Read the module's spec entry if useful: docs/moonmodules/light/${kind === 'effect' ? 'effects.md' : 'modifiers.md'} (find the ${cls.replace(/Effect$|Modifier$/, '')} section). 3. Read TWO existing tests as your pattern templates — match their idiom EXACTLY (includes, harness, naming, comment style): - For an EFFECT: test/unit/light/unit_RainbowEffect.cpp (Layouts→GridLayout→Layer→addChild(effect)→onBuildState()→loop()→assert on layer.buffer()). - For a MODIFIER: test/unit/light/unit_RegionModifier.cpp (call modifyLogical / modifyLogicalSize directly; assert coordinate folding / size). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 23cf38d4..72300c95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -525,14 +525,14 @@ jobs: cp src/ui/install-picker-boards.js pages/install/ # library.json — install page reads the project version from it. cp library.json pages/install/ - # Board picker images live in docs/assets/boards/ (the project's asset + # Board picker images live in docs/assets/deviceModels/ (the project's asset # home, also a library for boards not yet in the catalog). Stage ONLY the # images a deviceModels.json entry actually references, under install/assets/, - # so an "image": "assets/boards/.jpg" resolves same-origin from + # so an "image": "assets/deviceModels/.jpg" resolves same-origin from # /install/ without shipping the unused library to Pages. - mkdir -p pages/install/assets/boards - # rel is "assets/boards/." (the path served from /install/); - # the source file lives in docs/ (i.e. docs/assets/boards/...). + mkdir -p pages/install/assets/deviceModels + # rel is "assets/deviceModels/." (the path served from /install/); + # the source file lives in docs/ (i.e. docs/assets/deviceModels/...). jq -r '.[].image // empty' web-installer/deviceModels.json | while read -r rel; do src="docs/$rel" [ -f "$src" ] && cp "$src" "pages/install/$rel" \ diff --git a/CLAUDE.md b/CLAUDE.md index 4cd8d096..9f0c65a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ The design rationale for each rule below lives in [docs/architecture.md](docs/ar Then check the recommendation against [§ Principles](#principles) (minimalism, data over objects, concrete first) and propose it as a question, not a fait accompli. The product owner picks; the agent implements only what was picked. If the picked option turns out to need a follow-up change (e.g. an updated naming convention to make the new layout consistent), surface that *before* starting the move so it's a single coherent refactor, not three round-trips. -**Plan before implementing.** Use `/plan` mode before every feature. Review plans for: unnecessary files, inheritance where structs suffice, modifications outside the relevant directory. Reject and regenerate bad plans. **Save every approved plan** to `docs/history/plans/` named `Plan-YYYYMMDD - .md` (ISO-8601 date order so the directory sorts chronologically, e.g. `Plan-20260620 - Improv-as-REST.md` for 2026-06-20), as the first implementation step. The plan is the design record that complements `decisions.md` (the lesson record): the plan says what we set out to build and why; decisions.md captures what we learned doing it. **These saved plans are a reference archive for the product owner — agents WRITE a plan when creating one, but do NOT read the existing plan files for context unless the product owner explicitly points to one** (they're under the "Never automatically" rule below alongside the rest of `docs/history/`). Plans are **kept, not pruned** — they are the permanent design-intent record. When a plan's design ships (or doesn't), mark its outcome in the filename with a trailing parenthetical — `… (shipped).md` once it lands, `… (attempted, abandoned).md` if it was tried and dropped — so the directory shows at a glance what's done; an unmarked plan is still in flight. +**Plan before implementing.** Use `/plan` mode before every feature. Review plans for: unnecessary files, inheritance where structs suffice, modifications outside the relevant directory. Reject and regenerate bad plans. **Save every approved plan** to `docs/history/plans/` named `Plan-YYYYMMDD - <title>.md` (ISO-8601 date order so the directory sorts chronologically, e.g. `Plan-20260620 - Improv-as-REST.md` for 2026-06-20), as the first implementation step. The plan is the design record that complements `decisions.md` (the lesson record): the plan says what we set out to build and why; decisions.md captures what we learned doing it. **These saved plans are a reference archive for the product owner — agents WRITE a plan when creating one, but do NOT read the existing plan files for context unless the product owner explicitly points to one** (they're under the "Never automatically" rule below alongside the rest of `docs/history/`). Plans are **kept, not pruned** — they are the permanent design-intent record. When a plan's design ships (or doesn't), mark its outcome in the filename with a trailing parenthetical — `… (shipped).md` once it lands, `… (attempted, abandoned).md` if it was tried and dropped — so the directory shows at a glance what's done; an unmarked plan is still in flight. The **one exception** to "kept, not pruned": a multi-phase effort's per-phase plans may be **consolidated into a single `… (shipped).md` record** once the *whole* effort lands, provided that record preserves the design-intent arc (what each phase set out to do + the outcome, including any dead-ends). This isn't losing design intent — it's the same subtraction the rest of the process rewards, applied to a set of plans whose story is now one story. Consolidate only *shipped/settled* phases, never in-flight ones. **Use `uv` for every Python invocation.** Never type `python` or `python3` directly; always go through `uv run` (e.g. `uv run moondeck/build/build_desktop.py`, `uv run python -c "…"`). This applies to shell commands, CMake `add_custom_command` / `execute_process`, documentation examples, and anything that shells out. In CMake, resolve `find_program(UV_EXECUTABLE NAMES uv REQUIRED HINTS "$ENV{USERPROFILE}/.local/bin" "$ENV{HOME}/.local/bin")` once and use `${UV_EXECUTABLE} run python …` thereafter. Reason: uv manages the project venv and is the project standard ([moondeck/MoonDeck.md](moondeck/MoonDeck.md)); bare `python3` isn't on PATH on Windows (and macOS Python Launcher pops a Store prompt). If you catch yourself about to type `python`, stop and prefix with `uv run`. @@ -103,7 +103,7 @@ The narrow safety net: "this snapshot is internally consistent." **Always run (cheap, applies to every commit):** -1. Spec check, `check_specs.py`, fast (<1s), catches `docs/moonmodules/*.md` ↔ control-name drift even on doc-only commits. +1. Spec check, `check_specs.py`, fast (<1s), catches `docs/moonmodules/*.md` ↔ control-name drift even on doc-only commits, and validates every `main.cpp` `registerType` docPath resolves to a real page + `#anchor` (so a docs rename can't silently 404 the in-UI help links). **Conditional (run if trigger matches):** @@ -212,7 +212,7 @@ docs/ Documentation describes the system as it is. Git commits are the history. Module specs are written before implementation. Doc pages are kept current with the code. -**Documentation model** — the full standard lives in [docs/coding-standards.md § Documentation model](docs/coding-standards.md#documentation-model); the working-memory summary: the four **catalog pages** (`effects/modifiers/layouts/drivers.md`, authored as prose `### ` blocks, rendered as tables by the build hook) are the end-user documentation. A per-module detail `.md` exists **only** for cross-file wiring / design rationale no single source file owns (and doesn't exist otherwise). Test inventories + catalog tables are **generated at build time, not committed**. Where a fact is the same in the `.h` and a doc (a control range, a source URL), `check_specs.py` **validates** they agree rather than duplicating; where a doc would hand-copy a struct/enum/wire format, embed the real source via a `--8<--` snippet. Never re-type a fact the `.h` already states. +**Documentation model** — the full standard lives in [docs/coding-standards.md § Documentation model](docs/coding-standards.md#documentation-model); the working-memory summary: every module has **two surfaces** — a hand-written **summary/catalog page** (one flat page per group under its domain: `light/{effects,modifiers,layouts,drivers,supporting}.md`, `core/{services,supporting,ui}.md`; authored as prose `### ` blocks, rendered as tables by the build hook) for the end user, and a **generated technical page** (`<domain>/moxygen/<Module>.md`, from the `.h`'s `///` comments) for the developer. Each summary card's Links column links to the technical page via a `Detail: [technical]` line (🧪 Tests · 📄 Technical, icon'd by the hook) — **not to the `.h`** (the technical page already links the `.h`). Rich rationale therefore goes in `///` (not `//` — `//` is invisible to moxygen), directly above the class; a `@card <file.png>` line adds the UI screenshot; relative `.md` links are stripped by Doxygen, so use full URLs. Shared rationale across sibling modules lives once on the base class's `///`. Test inventories + catalog tables are **generated at build time, not committed**. Where a fact is the same in the `.h` and a doc, `check_specs.py` **validates** they agree (control names, ranges, author URLs, and every `main.cpp` `registerType` docPath resolving to a real page + `#anchor`); embed a struct/enum/wire format via a `--8<--` snippet. Never re-type a fact the `.h` already states. The `history/` folder is the distilled experience of years of building LED/light systems, from WLED, WLED-MM, StarLight, MoonLight, through projectMM. It contains proven patterns, memory tricks, control mechanisms, and hard-won lessons, studied under the [*Industry standards, our own code*](#principles) principle. Per-project credits live in the `history/` digests and the per-module "Prior art" sections. diff --git a/README.md b/README.md index 0f3e1900..71f89805 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,9 @@ If you like projectMM, give it a ⭐️, fork it, or open an issue or pull reque 🎵 **Audio-reactive**: an I²S microphone drives a 16-band FFT spectrum + sound level, consumed by audio-reactive effects — all built fresh from the mic datasheet and textbook DSP. -🏠 **Home-automation control**: a device joins Homebridge (and any MQTT hub) over a dependency-free MQTT 3.1.1 client — on/off, brightness, and a HomeKit colour wheel that picks the nearest palette. See [the MQTT module docs](docs/moonmodules/core/ui/ui.md#mqtt). +🏠 **Home-automation control**: a device joins Homebridge (and any MQTT hub) over a dependency-free MQTT 3.1.1 client — on/off, brightness, and a HomeKit colour wheel that picks the nearest palette. See [the MQTT module docs](docs/moonmodules/core/services.md#mqtt). -📁 **On-device File Manager**: browse and edit the device filesystem from the browser — a lazy folder tree with an inline editor, drag-drop upload, and create/delete, plus [firmware upload OTA](docs/moonmodules/core/ui/ui.md#firmware-update) (flash a `.bin` over the LAN, no USB). See [the File Manager docs](docs/moonmodules/core/ui/ui.md#file-manager). +📁 **On-device File Manager**: browse and edit the device filesystem from the browser — a lazy folder tree with an inline editor, drag-drop upload, and create/delete, plus [firmware upload OTA](docs/moonmodules/core/services.md#firmware-update) (flash a `.bin` over the LAN, no USB). See [the File Manager docs](docs/moonmodules/core/services.md#file-manager). 🛡️ **Robust to any input**: add, delete, replace, or reconfigure any module in any order, at any grid size, and the device keeps running — degraded or idle, never crashed. Every crash that's ever found becomes a regression test, so it stays fixed. diff --git a/docs/architecture.md b/docs/architecture.md index b6f4649e..5bddabb2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,7 +121,7 @@ Controls are dynamic: when a value changes, the control set can be rebuilt. A se Prefer `uint8_t` (0–255) for slider controls. Minimises per-control memory, aligns with DMX channel values, keeps the UI range manageable. -Controls are the bridge between the [web UI](moonmodules/core/ui/ui.md) and the running module tree: the UI renders a control from what the MoonModule declares, and a value the user changes there writes straight back into the module's member variable. The exact control types (slider, toggle, colour picker, text input, dropdown) are defined in the [UI spec](moonmodules/core/ui/ui.md). The principle: modules declare what they need, the UI renders it. +Controls are the bridge between the [web UI](moonmodules/core/ui.md) and the running module tree: the UI renders a control from what the MoonModule declares, and a value the user changes there writes straight back into the module's member variable. The exact control types (slider, toggle, colour picker, text input, dropdown) are defined in the [UI spec](moonmodules/core/ui.md#control-types). The principle: modules declare what they need, the UI renders it. ## Persistence @@ -259,7 +259,7 @@ The defining line is the **data relationship, not the connector**: *does the mod Peripherals are **user-add/deletable children of SystemModule**: the firmware is identical whether or not the hardware is wired, so the user adds the module when they solder a gyro on and removes it later, reusing the generic child add/replace/delete + persistence machinery (SystemModule declares `acceptsChildRoles("peripheral")`). Direction is per-module, not a role: a peripheral may read (gyro), write (relay), or both, so one `Peripheral` role spans the category. Each is a header-only or `.h`+`.cpp` core module under `src/core/`, reaches hardware only through a domain-neutral platform primitive (`platform::i2c*`, `platform::audioMic*`, …), and gets a spec in `docs/moonmodules/core/` (enforced by `check_specs.py`). Most poll in `loop20ms`/`loop1s`; the exception is a peripheral whose data an effect consumes *every frame*: [AudioModule](moonmodules/core/moxygen/AudioModule.md) reads + analyses its I²S microphone in `loop()` because the audio effects react per render tick, and its per-tick cost (one FFT) is part of the render budget. Automatic bus-probe detection is out of scope; the manual path is the foundation. -**An effect reads a peripheral's data** via the shared-struct pull pattern from [§ Data exchange](#data-exchange-between-modules), no new mechanism: the peripheral owns a small POD struct overwritten in place each poll/tick, and the consuming effect holds a `const` pointer to it. The first concrete case is audio: AudioModule produces an `AudioFrame` (level + 16-band spectrum + peak) that [AudioVolumeEffect](moonmodules/light/effects/effects.md) and [AudioSpectrumEffect](moonmodules/light/effects/effects.md) consume. It reaches the frame through a static `AudioModule::latestFrame()` rather than a boot-time setter, a small variation on the pattern, because an audio effect can be added through the UI *after* boot and must still find the one live mic (a setter only wired the boot instance). The active mic registers itself in `setup()` and clears the pointer in `teardown()`, so add/remove in any order returns either the live frame or a static silent one, never null. A peripheral that only *displays* its readings (the gyro today) skips the consumer side entirely. +**An effect reads a peripheral's data** via the shared-struct pull pattern from [§ Data exchange](#data-exchange-between-modules), no new mechanism: the peripheral owns a small POD struct overwritten in place each poll/tick, and the consuming effect holds a `const` pointer to it. The first concrete case is audio: AudioModule produces an `AudioFrame` (level + 16-band spectrum + peak) that [AudioVolumeEffect](moonmodules/light/effects.md) and [AudioSpectrumEffect](moonmodules/light/effects.md) consume. It reaches the frame through a static `AudioModule::latestFrame()` rather than a boot-time setter, a small variation on the pattern, because an audio effect can be added through the UI *after* boot and must still find the one live mic (a setter only wired the boot instance). The active mic registers itself in `setup()` and clears the pointer in `teardown()`, so add/remove in any order returns either the live frame or a static silent one, never null. A peripheral that only *displays* its readings (the gyro today) skips the consumer side entirely. ## Multi-device runtime @@ -311,7 +311,7 @@ Modules in the light pipeline can be added, replaced, or removed dynamically at **Data flow.** The pipeline instantiates both core data-exchange shapes (see [§ Data exchange between modules](#data-exchange-between-modules)): - *Shared-struct (pull):* `Drivers` hands every child driver a `Buffer*` (source) plus a `Correction*` (shared brightness/reorder/white), and `Layer` exposes its pixel buffer to `Drivers` directly on the identity-mapping fast path: each consumer holds a `const`-pointer and reads it per frame. The pointers are **(re)bound on every rebuild**, not just at boot: `Drivers::onBuildState()` re-resolves the active `Layer` (`Layers::activeLayer()`) and calls `passBufferToDrivers()`, which re-runs `setSourceBuffer()`/`setLayer()` on each child (clearing them to `nullptr` when there is no active Layer). So a held pointer is valid only until the next rebuild — which is exactly why the consumers re-read it each frame and tolerate a null (the [robustness rule](#robustness)): a Layer add/delete/replace re-binds or clears it live, no dangling reference. -- *Push to a core sink:* `PreviewDriver` owns the preview wire format (a one-time coordinate table + per-frame RGB point list) and pushes the bytes to a `BinaryBroadcaster` (the core HTTP server). The server broadcasts them over WebSocket without knowing they're a preview: the format and the light types stay entirely in the driver. See [PreviewDriver](moonmodules/light/drivers/PreviewDriver.md). +- *Push to a core sink:* `PreviewDriver` owns the preview wire format (a one-time coordinate table + per-frame RGB point list) and pushes the bytes to a `BinaryBroadcaster` (the core HTTP server). The server broadcasts them over WebSocket without knowing they're a preview: the format and the light types stay entirely in the driver. See [PreviewDriver](moonmodules/light/moxygen/PreviewDriver.md). **Graceful degradation under transport backpressure.** The preview is the project's worked example of a property worth naming generically, because it is the transport-side sibling of the memory-side [§ Degradation cascade](#degradation-cascade): when a consumer can't keep up, **shed quality first** — degrade the stream rather than reach for a stall. The link to a browser is the slow consumer; a full-resolution frame (128² = 16384 lights = ~49 KB) may not drain in the budget one tick allows. Rather than block the loop until it drains, the producer **degrades**, shedding in the order video streaming does — frame rate first, then resolution: (1) the full-resolution frame streams from the driver buffer with no intermediate copy, drained a memory-adaptive chunk per transport tick (a **resumable** send), and the next frame starts only when the previous one finished — so the **effective frame rate self-limits** to what the link sustains, with no loop stall and no connection drop; (2) only when even one frame can't drain promptly does it shed **resolution** via a spatial-lattice downsample, the same congestion-responsive, adaptive-bitrate idea behind HLS/DASH/WebRTC applied to a binary WebSocket. The point budget is itself memory-derived (per [§ Scaling to available memory](#scaling-to-available-memory)), so a tighter board downsamples sooner. The render loop is charged only a bounded slice per tick; each delivered frame is a faithful **complete** frame at a lower rate or coarser sample — a WebSocket message is atomic to the browser, so a frame is whole or absent, never partial/torn. The coordinate table and the downsampled frames take a bounded synchronous send (begin/push/end): a client whose socket stays blocked past the spin budget is closed and reconnects (the browser re-handshakes and the next coordinate table re-primes it) — the bound caps tick occupancy, and a reconnect is a brief blip rather than a frozen preview. This is *graceful degradation*: a fast link sees every light at full rate, a slow link sees a faithful coarser sample at a few fps, and a wedged client is dropped and recovers rather than stalling the device. The mechanism (resumable cross-tick send + drop-new backpressure — a frame offered while one is in flight is dropped, the in-flight one is kept — plus adaptive frame rate + adaptive lattice) lives in `PreviewDriver` + `HttpServerModule`; it is payload-agnostic, so other bulky streams can ride the same transport. @@ -392,7 +392,7 @@ Every effect declares its native dimensionality through `EffectBase::dimensions( D1/D2 are **opt-in promises**: declaring them tells the framework it can fill the missing axes, saving the per-effect work of iterating z (or x and z). Effects that don't make that promise stay at the D3 default and iterate the whole buffer. -**Why 1D runs along Y, and the unified expand rule.** A lower-D effect occupies the *low* axes and the framework expands it across the *next* axis: **1D → 2D adds columns across X** (the 1D output is the first column, duplicated rightward); **2D → 3D adds slices across Z** (the 2D front face, duplicated in depth). 1D-along-Y is the deliberate choice (shared with MoonLight): it makes a 1D effect the natural **first column** of its 2D form — write the effect once down Y, and expanding to a panel is just "repeat the column," same math, no special-casing. (The alternative, 1D-along-X, would make 1D a *row* that expands *downward* — a less natural fit, since a strip is a column and a 2D effect's columns are what you tile.) Concretely: a 1D effect draws its shape down Y, so it renders correctly on a layer whose single populated axis is Y (a `1 × N` grid, width 1, height N); on an `N × 1` grid the extrude would run the wrong way and collapse the shape to a flat line. How a physical output (a strip, a row of [Hue lights](moonmodules/light/drivers/HueDriver.md)) maps to a `1 × N` grid is a layout concern — see the layout docs. +**Why 1D runs along Y, and the unified expand rule.** A lower-D effect occupies the *low* axes and the framework expands it across the *next* axis: **1D → 2D adds columns across X** (the 1D output is the first column, duplicated rightward); **2D → 3D adds slices across Z** (the 2D front face, duplicated in depth). 1D-along-Y is the deliberate choice (shared with MoonLight): it makes a 1D effect the natural **first column** of its 2D form — write the effect once down Y, and expanding to a panel is just "repeat the column," same math, no special-casing. (The alternative, 1D-along-X, would make 1D a *row* that expands *downward* — a less natural fit, since a strip is a column and a 2D effect's columns are what you tile.) Concretely: a 1D effect draws its shape down Y, so it renders correctly on a layer whose single populated axis is Y (a `1 × N` grid, width 1, height N); on an `N × 1` grid the extrude would run the wrong way and collapse the shape to a flat line. How a physical output (a strip, a row of [Hue lights](moonmodules/light/moxygen/HueDriver.md)) maps to a `1 × N` grid is a layout concern — see the layout docs. Hot-path cost: extrude pays one comparison and returns for the D3 case. For D1/D2 on a layer whose unused axes are size 1 (a D2 effect on a 2D layer, a D1 effect on a 1D `1 × N` layer) the inner loops are guarded by `depth_ > 1` / `width_ > 1` and never run. Real `memcpy` work happens only for a D1 or D2 effect on a layer with more dimensions than the effect writes: exactly the case where you wanted the framework to do the duplication. @@ -425,7 +425,7 @@ The engine is a **domain-neutral core** with one narrow seam, structured as thre - **Host builtin table** (the domain seam): the core owns no function names. A *host* registers `{name → descriptor}` — `setRGB`/`fill`/`random16` for LEDs (`src/light/moonlive/`), something else for a display or sensor. A descriptor is either a `Call` (a generic call to a host C function pointer — a pure helper like `random16`) or an `Inline` op (a neutral opcode tag the backend emits inline — a buffer writer, no per-pixel call). This is the [ESPLiveScript / ARTI bound-function model](backlog/livescripts-analysis-top-down.md); it is what keeps the core LED-free while the hot path stays inline. The LED *names* and the "an element is 3 RGB bytes" meaning live only in the light-domain registration and the per-ISA lowering, never in core. - **Per-ISA backend** (`src/platform/`, behind the boundary): a tiny named-instruction MacroAssembler (the textbook V8 / LLVM / asmjit shape — append one instruction, back-patch label offsets) plus the IR→bytes lowering that drives it. Xtensa (classic ESP32 / S3), RISC-V (P4), and the host ISA (desktop arm64/x86-64) each are *a new backend file behind the unchanged IR* — the front-end and IR never branch on ISA. Emitted code goes into an `allocExec` block (see [§ Platform abstraction](#platform-abstraction)) and is called each tick. -A recompile is the normal cold-path rebuild: editing the `source` control routes through the same `onBuildState()` sweep every control change uses, so a new script swaps in live (no reboot), and a parse error surfaces in the module status while the layer renders dark — robust to any input. The full design (the staged language ladder, the safety model, the performance budget, the memory-arena plan as the language grows) lives in [docs/backlog/livescripts-analysis-top-down.md](backlog/livescripts-analysis-top-down.md); the module contract is [docs/moonmodules/light/moonlive/MoonLiveEffect.md](moonmodules/light/moonlive/MoonLiveEffect.md). +A recompile is the normal cold-path rebuild: editing the `source` control routes through the same `onBuildState()` sweep every control change uses, so a new script swaps in live (no reboot), and a parse error surfaces in the module status while the layer renders dark — robust to any input. The full design (the staged language ladder, the safety model, the performance budget, the memory-arena plan as the language grows) lives in [docs/backlog/livescripts-analysis-top-down.md](backlog/livescripts-analysis-top-down.md); the module contract is [docs/moonmodules/light/MoonLiveEffect.md](moonmodules/light/MoonLiveEffect.md). ## Modifiers @@ -461,7 +461,7 @@ The shared output buffer is necessary when blend+map writes to arbitrary physica Each driver (a MoonModule) speaks one protocol: -- **LED drivers**: WS2812 via RMT (multi-pin), plus two parallel-output paths on the newer chips. The S3's LCD_CAM i80 bus ([LcdLedDriver](moonmodules/light/drivers/LcdLedDriver.md)) drives exactly 8 data GPIOs — the i80 bus claims every data line of its width, so a partial set is rejected. The P4's Parlio peripheral ([ParlioLedDriver](moonmodules/light/drivers/ParlioLedDriver.md)) drives 1–8 lanes — it takes the data GPIOs directly, so any count up to 8 is valid. Both are DMA-driven. Platform-specific; all behind the platform boundary. +- **LED drivers**: WS2812 via RMT (multi-pin), plus two parallel-output paths on the newer chips. The S3's LCD_CAM i80 bus ([LcdLedDriver](moonmodules/light/moxygen/LcdLedDriver.md)) drives exactly 8 data GPIOs — the i80 bus claims every data line of its width, so a partial set is rejected. The P4's Parlio peripheral ([ParlioLedDriver](moonmodules/light/moxygen/ParlioLedDriver.md)) drives 1–8 lanes — it takes the data GPIOs directly, so any count up to 8 is valid. Both are DMA-driven. Platform-specific; all behind the platform boundary. - **DMX / ArtNet**: sends DMX over UDP. Supports addressable LEDs and conventional DMX fixtures (pars, moving heads, dimmers). - **Preview**: streams light data to the web UI via WebSocket. - **Desktop output**: SDL2 or terminal for visual preview. Desktop also serves as a high-speed processing node, driving lights via ArtNet/DDP over the network. @@ -547,7 +547,7 @@ The UI is **MoonModule-driven**. It contains no hard-coded knowledge of specific Adding a new MoonModule with controls needs **zero changes** to the UI files. This extends to the tree-mutation affordances: which modules accept children (and of what role) comes from each type's `acceptsChildRoles()`, and whether a module can be deleted/replaced comes from its `userEditable()`: both declared on the C++ side and reported in `/api/types` + `/api/state`. The UI hardcodes no list of "which types are containers" or "which roles are editable"; a new container type or a fixed child is a one-line C++ override. -The light domain plugs into the UI at three points: a fixed top-level tree (Layouts / Layers / Drivers pinned in `main.cpp`, root reorder disabled while child reorder works via drag-and-drop), a binary WebSocket preview channel ([PreviewDriver](moonmodules/light/drivers/PreviewDriver.md): a `0x03` coordinate table sent once per LUT rebuild plus per-frame `0x02` RGB point lists, so sparse layouts preview at their real positions), and per-role emoji for the chip filter (the `ROLE_EMOJI` map in `app.js` is the single source of truth: `effect`, `driver`, …, `peripheral`). Full UI spec: [docs/moonmodules/core/ui/ui.md](moonmodules/core/ui/ui.md). +The light domain plugs into the UI at three points: a fixed top-level tree (Layouts / Layers / Drivers pinned in `main.cpp`, root reorder disabled while child reorder works via drag-and-drop), a binary WebSocket preview channel ([PreviewDriver](moonmodules/light/moxygen/PreviewDriver.md): a `0x03` coordinate table sent once per LUT rebuild plus per-frame `0x02` RGB point lists, so sparse layouts preview at their real positions), and per-role emoji for the chip filter (the `ROLE_EMOJI` map in `app.js` is the single source of truth: `effect`, `driver`, …, `peripheral`). Full UI spec: [docs/moonmodules/core/ui.md](moonmodules/core/ui.md). ## Tag emoji legend diff --git a/docs/assets/core/AudioModule.png b/docs/assets/core/AudioModule.png new file mode 100644 index 00000000..a6ce5a1b Binary files /dev/null and b/docs/assets/core/AudioModule.png differ diff --git a/docs/assets/core/DevicesModule.png b/docs/assets/core/DevicesModule.png new file mode 100644 index 00000000..f5eeece7 Binary files /dev/null and b/docs/assets/core/DevicesModule.png differ diff --git a/docs/assets/core/FileManagerModule.png b/docs/assets/core/FileManagerModule.png new file mode 100644 index 00000000..9efcd8a2 Binary files /dev/null and b/docs/assets/core/FileManagerModule.png differ diff --git a/docs/assets/core/FirmwareUpdateModule.png b/docs/assets/core/FirmwareUpdateModule.png index dd214f48..70ab5e0d 100644 Binary files a/docs/assets/core/FirmwareUpdateModule.png and b/docs/assets/core/FirmwareUpdateModule.png differ diff --git a/docs/assets/core/Hue device disco.png b/docs/assets/core/Hue device disco.png deleted file mode 100644 index cec4b461..00000000 Binary files a/docs/assets/core/Hue device disco.png and /dev/null differ diff --git a/docs/assets/core/I2cScanModule.png b/docs/assets/core/I2cScanModule.png new file mode 100644 index 00000000..04a608be Binary files /dev/null and b/docs/assets/core/I2cScanModule.png differ diff --git a/docs/assets/core/ImprovProvisioningModule.png b/docs/assets/core/ImprovProvisioningModule.png new file mode 100644 index 00000000..60068d70 Binary files /dev/null and b/docs/assets/core/ImprovProvisioningModule.png differ diff --git a/docs/assets/core/IrModule.jpeg b/docs/assets/core/IrModule.jpeg deleted file mode 100644 index d40c192e..00000000 Binary files a/docs/assets/core/IrModule.jpeg and /dev/null differ diff --git a/docs/assets/core/IrModule.png b/docs/assets/core/IrModule.png new file mode 100644 index 00000000..a62a0951 Binary files /dev/null and b/docs/assets/core/IrModule.png differ diff --git a/docs/assets/core/MqttModule.png b/docs/assets/core/MqttModule.png new file mode 100644 index 00000000..d7ff9b29 Binary files /dev/null and b/docs/assets/core/MqttModule.png differ diff --git a/docs/assets/core/NetworkModule.png b/docs/assets/core/NetworkModule.png index 4af3b8b6..c18ea36e 100644 Binary files a/docs/assets/core/NetworkModule.png and b/docs/assets/core/NetworkModule.png differ diff --git a/docs/assets/core/SystemModule.png b/docs/assets/core/SystemModule.png index 9f2738c8..45c5fa31 100644 Binary files a/docs/assets/core/SystemModule.png and b/docs/assets/core/SystemModule.png differ diff --git a/docs/assets/boards/ESP32-P4-NANO-details-inter.jpg b/docs/assets/deviceModels/ESP32-P4-NANO-details-inter.jpg similarity index 100% rename from docs/assets/boards/ESP32-P4-NANO-details-inter.jpg rename to docs/assets/deviceModels/ESP32-P4-NANO-details-inter.jpg diff --git a/docs/assets/boards/ch9102.jpg b/docs/assets/deviceModels/ch9102.jpg similarity index 100% rename from docs/assets/boards/ch9102.jpg rename to docs/assets/deviceModels/ch9102.jpg diff --git a/docs/assets/boards/cp210x-ch34x.jpg b/docs/assets/deviceModels/cp210x-ch34x.jpg similarity index 100% rename from docs/assets/boards/cp210x-ch34x.jpg rename to docs/assets/deviceModels/cp210x-ch34x.jpg diff --git a/docs/assets/boards/esp32-c3-supermini.jpg b/docs/assets/deviceModels/esp32-c3-supermini.jpg similarity index 100% rename from docs/assets/boards/esp32-c3-supermini.jpg rename to docs/assets/deviceModels/esp32-c3-supermini.jpg diff --git a/docs/assets/boards/esp32-c3.jpg b/docs/assets/deviceModels/esp32-c3.jpg similarity index 100% rename from docs/assets/boards/esp32-c3.jpg rename to docs/assets/deviceModels/esp32-c3.jpg diff --git a/docs/assets/boards/esp32-d0-16mb.jpg b/docs/assets/deviceModels/esp32-d0-16mb.jpg similarity index 100% rename from docs/assets/boards/esp32-d0-16mb.jpg rename to docs/assets/deviceModels/esp32-d0-16mb.jpg diff --git a/docs/assets/boards/esp32-d0-pico2.jpg b/docs/assets/deviceModels/esp32-d0-pico2.jpg similarity index 100% rename from docs/assets/boards/esp32-d0-pico2.jpg rename to docs/assets/deviceModels/esp32-d0-pico2.jpg diff --git a/docs/assets/boards/esp32-d0-wrover.jpg b/docs/assets/deviceModels/esp32-d0-wrover.jpg similarity index 100% rename from docs/assets/boards/esp32-d0-wrover.jpg rename to docs/assets/deviceModels/esp32-d0-wrover.jpg diff --git a/docs/assets/boards/esp32-p4-eth.png b/docs/assets/deviceModels/esp32-p4-eth.png similarity index 100% rename from docs/assets/boards/esp32-p4-eth.png rename to docs/assets/deviceModels/esp32-p4-eth.png diff --git a/docs/assets/boards/esp32-p4-olimex.jpg b/docs/assets/deviceModels/esp32-p4-olimex.jpg similarity index 100% rename from docs/assets/boards/esp32-p4-olimex.jpg rename to docs/assets/deviceModels/esp32-p4-olimex.jpg diff --git a/docs/assets/boards/esp32-p4.jpg b/docs/assets/deviceModels/esp32-p4.jpg similarity index 100% rename from docs/assets/boards/esp32-p4.jpg rename to docs/assets/deviceModels/esp32-p4.jpg diff --git a/docs/assets/boards/esp32-s3-atoms3r.jpg b/docs/assets/deviceModels/esp32-s3-atoms3r.jpg similarity index 100% rename from docs/assets/boards/esp32-s3-atoms3r.jpg rename to docs/assets/deviceModels/esp32-s3-atoms3r.jpg diff --git a/docs/assets/boards/esp32-s3-n16r8-dev.jpg b/docs/assets/deviceModels/esp32-s3-n16r8-dev.jpg similarity index 100% rename from docs/assets/boards/esp32-s3-n16r8-dev.jpg rename to docs/assets/deviceModels/esp32-s3-n16r8-dev.jpg diff --git a/docs/assets/boards/esp32-s3-n8r8-dev.jpg b/docs/assets/deviceModels/esp32-s3-n8r8-dev.jpg similarity index 100% rename from docs/assets/boards/esp32-s3-n8r8-dev.jpg rename to docs/assets/deviceModels/esp32-s3-n8r8-dev.jpg diff --git a/docs/assets/boards/esp32-s3-seeed_xiao.png b/docs/assets/deviceModels/esp32-s3-seeed_xiao.png similarity index 100% rename from docs/assets/boards/esp32-s3-seeed_xiao.png rename to docs/assets/deviceModels/esp32-s3-seeed_xiao.png diff --git a/docs/assets/boards/esp32-s3-stephanelec-16p.jpg b/docs/assets/deviceModels/esp32-s3-stephanelec-16p.jpg similarity index 100% rename from docs/assets/boards/esp32-s3-stephanelec-16p.jpg rename to docs/assets/deviceModels/esp32-s3-stephanelec-16p.jpg diff --git a/docs/assets/boards/esp32-s3-zero-n4r2.jpg b/docs/assets/deviceModels/esp32-s3-zero-n4r2.jpg similarity index 100% rename from docs/assets/boards/esp32-s3-zero-n4r2.jpg rename to docs/assets/deviceModels/esp32-s3-zero-n4r2.jpg diff --git a/docs/assets/boards/esp32-s31-function-coreboard-1.jpg b/docs/assets/deviceModels/esp32-s31-function-coreboard-1.jpg similarity index 100% rename from docs/assets/boards/esp32-s31-function-coreboard-1.jpg rename to docs/assets/deviceModels/esp32-s31-function-coreboard-1.jpg diff --git a/docs/assets/boards/generic-esp32-dev.jpg b/docs/assets/deviceModels/generic-esp32-dev.jpg similarity index 100% rename from docs/assets/boards/generic-esp32-dev.jpg rename to docs/assets/deviceModels/generic-esp32-dev.jpg diff --git a/docs/assets/boards/lightcrafter-16.jpg b/docs/assets/deviceModels/lightcrafter-16.jpg similarity index 100% rename from docs/assets/boards/lightcrafter-16.jpg rename to docs/assets/deviceModels/lightcrafter-16.jpg diff --git a/docs/assets/boards/lolin-d32.jpg b/docs/assets/deviceModels/lolin-d32.jpg similarity index 100% rename from docs/assets/boards/lolin-d32.jpg rename to docs/assets/deviceModels/lolin-d32.jpg diff --git a/docs/assets/boards/mhc-v57-pro.jpg b/docs/assets/deviceModels/mhc-v57-pro.jpg similarity index 100% rename from docs/assets/boards/mhc-v57-pro.jpg rename to docs/assets/deviceModels/mhc-v57-pro.jpg diff --git a/docs/assets/boards/mhc-wled-esp32-p4-shield.jpg b/docs/assets/deviceModels/mhc-wled-esp32-p4-shield.jpg similarity index 100% rename from docs/assets/boards/mhc-wled-esp32-p4-shield.jpg rename to docs/assets/deviceModels/mhc-wled-esp32-p4-shield.jpg diff --git a/docs/assets/boards/olimex-esp32-gateway-rev-g.jpg b/docs/assets/deviceModels/olimex-esp32-gateway-rev-g.jpg similarity index 100% rename from docs/assets/boards/olimex-esp32-gateway-rev-g.jpg rename to docs/assets/deviceModels/olimex-esp32-gateway-rev-g.jpg diff --git a/docs/assets/boards/others.jpg b/docs/assets/deviceModels/others.jpg similarity index 100% rename from docs/assets/boards/others.jpg rename to docs/assets/deviceModels/others.jpg diff --git a/docs/assets/boards/quinled-dig-2-go.jpg b/docs/assets/deviceModels/quinled-dig-2-go.jpg similarity index 100% rename from docs/assets/boards/quinled-dig-2-go.jpg rename to docs/assets/deviceModels/quinled-dig-2-go.jpg diff --git a/docs/assets/boards/quinled-dig-next-2.jpg b/docs/assets/deviceModels/quinled-dig-next-2.jpg similarity index 100% rename from docs/assets/boards/quinled-dig-next-2.jpg rename to docs/assets/deviceModels/quinled-dig-next-2.jpg diff --git a/docs/assets/boards/quinled-dig-octa-32-8l.jpg b/docs/assets/deviceModels/quinled-dig-octa-32-8l.jpg similarity index 100% rename from docs/assets/boards/quinled-dig-octa-32-8l.jpg rename to docs/assets/deviceModels/quinled-dig-octa-32-8l.jpg diff --git a/docs/assets/boards/quinled-dig-quad-v3.jpg b/docs/assets/deviceModels/quinled-dig-quad-v3.jpg similarity index 100% rename from docs/assets/boards/quinled-dig-quad-v3.jpg rename to docs/assets/deviceModels/quinled-dig-quad-v3.jpg diff --git a/docs/assets/boards/quinled-dig-uno-v3.jpg b/docs/assets/deviceModels/quinled-dig-uno-v3.jpg similarity index 100% rename from docs/assets/boards/quinled-dig-uno-v3.jpg rename to docs/assets/deviceModels/quinled-dig-uno-v3.jpg diff --git a/docs/assets/boards/serg-minishield.jpg b/docs/assets/deviceModels/serg-minishield.jpg similarity index 100% rename from docs/assets/boards/serg-minishield.jpg rename to docs/assets/deviceModels/serg-minishield.jpg diff --git a/docs/assets/boards/serg-unishield-v5.jpg b/docs/assets/deviceModels/serg-unishield-v5.jpg similarity index 100% rename from docs/assets/boards/serg-unishield-v5.jpg rename to docs/assets/deviceModels/serg-unishield-v5.jpg diff --git a/docs/assets/boards/shelly.jpg b/docs/assets/deviceModels/shelly.jpg similarity index 100% rename from docs/assets/boards/shelly.jpg rename to docs/assets/deviceModels/shelly.jpg diff --git a/docs/assets/boards/waveshare-esp32-p4-nano.jpg b/docs/assets/deviceModels/waveshare-esp32-p4-nano.jpg similarity index 100% rename from docs/assets/boards/waveshare-esp32-p4-nano.jpg rename to docs/assets/deviceModels/waveshare-esp32-p4-nano.jpg diff --git a/docs/assets/extra.css b/docs/assets/extra.css index e4dbf88f..19c3ab00 100644 --- a/docs/assets/extra.css +++ b/docs/assets/extra.css @@ -86,3 +86,41 @@ .md-typeset .mm-catalog-wrap .mm-links { color: var(--md-default-fg-color--light); } + +/* --------------------------------------------------------------------------- + Coloured headings (whole site). The slate theme renders every heading in the + same near-white, so an h1/h2/h3 doesn't stand out from body text — the page + reads as one grey wall. Give headings the theme's primary/accent colour so + the document structure is scannable at a glance (the standard Material-site + look: coloured headings over neutral body). h4+ stay default — colouring + every level would just re-flatten the hierarchy. */ +.md-typeset h1, +.md-typeset h2 { + color: var(--md-primary-fg-color); + font-weight: 700; +} +.md-typeset h3 { + color: var(--md-accent-fg-color); + font-weight: 600; +} + +/* --------------------------------------------------------------------------- + Signature standout on the generated moxygen technical pages. Each attribute / + method renders as a paragraph leading with its declaration + (`uint8_t protocol = 0`, `virtual void onBuildControls() override`) followed + by a `: summary`. gen_api.py rewrites each into + `<code class="mm-sig">…<span class="mm-sig-name">name</span>…</code>` so the + chip can highlight ONLY the declared member name while the type and arguments + stay muted — the eye lands on the name it's scanning for, and the signature + still reads as one code chip. (A flat `<code>` can't do this in CSS alone — + there's no way to select 'the name' inside an undelimited string.) */ +.md-typeset code.mm-sig { + background: color-mix(in srgb, var(--md-accent-fg-color) 7%, transparent); + border: 1px solid color-mix(in srgb, var(--md-accent-fg-color) 22%, transparent); + color: var(--md-default-fg-color--light); /* type + args: muted */ + padding: 0.15em 0.5em; +} +.md-typeset code.mm-sig .mm-sig-name { + color: var(--md-accent-fg-color); /* the declared name: accent + bold */ + font-weight: 700; +} diff --git a/docs/assets/light/Drivers.png b/docs/assets/light/Drivers.png new file mode 100644 index 00000000..928c6122 Binary files /dev/null and b/docs/assets/light/Drivers.png differ diff --git a/docs/assets/light/Layer.png b/docs/assets/light/Layer.png new file mode 100644 index 00000000..3a6480b4 Binary files /dev/null and b/docs/assets/light/Layer.png differ diff --git a/docs/assets/light/Layers.png b/docs/assets/light/Layers.png new file mode 100644 index 00000000..69dd5637 Binary files /dev/null and b/docs/assets/light/Layers.png differ diff --git a/docs/assets/light/Layouts.png b/docs/assets/light/Layouts.png new file mode 100644 index 00000000..fb2b95d8 Binary files /dev/null and b/docs/assets/light/Layouts.png differ diff --git a/docs/assets/light/drivers/Hue driver.png b/docs/assets/light/drivers/HueDriver.png similarity index 100% rename from docs/assets/light/drivers/Hue driver.png rename to docs/assets/light/drivers/HueDriver.png diff --git a/docs/assets/light/drivers/NetworkSendDriver.png b/docs/assets/light/drivers/NetworkSendDriver.png index 8e7a2fa8..364e9da1 100644 Binary files a/docs/assets/light/drivers/NetworkSendDriver.png and b/docs/assets/light/drivers/NetworkSendDriver.png differ diff --git a/docs/assets/light/drivers/PreviewDriver.png b/docs/assets/light/drivers/PreviewDriver.png index 45329947..ab0ba973 100644 Binary files a/docs/assets/light/drivers/PreviewDriver.png and b/docs/assets/light/drivers/PreviewDriver.png differ diff --git a/docs/assets/light/drivers/RmtLedDriver.png b/docs/assets/light/drivers/RmtLedDriver.png new file mode 100644 index 00000000..62d3058e Binary files /dev/null and b/docs/assets/light/drivers/RmtLedDriver.png differ diff --git a/docs/assets/light/effects/FireEffect.png b/docs/assets/light/effects/FireEffect.png index 0186008e..ee5f2f25 100644 Binary files a/docs/assets/light/effects/FireEffect.png and b/docs/assets/light/effects/FireEffect.png differ diff --git a/docs/assets/light/effects/LavaLampEffect.png b/docs/assets/light/effects/LavaLampEffect.png deleted file mode 100644 index 3a5e8384..00000000 Binary files a/docs/assets/light/effects/LavaLampEffect.png and /dev/null differ diff --git a/docs/assets/light/effects/LinesEffect.png b/docs/assets/light/effects/LinesEffect.png index 4b0bbd37..71b22075 100644 Binary files a/docs/assets/light/effects/LinesEffect.png and b/docs/assets/light/effects/LinesEffect.png differ diff --git a/docs/assets/light/effects/MetaballsEffect.png b/docs/assets/light/effects/MetaballsEffect.png index a107d66c..a8b3cda0 100644 Binary files a/docs/assets/light/effects/MetaballsEffect.png and b/docs/assets/light/effects/MetaballsEffect.png differ diff --git a/docs/assets/light/effects/NoiseEffect.png b/docs/assets/light/effects/NoiseEffect.png deleted file mode 100644 index b8666695..00000000 Binary files a/docs/assets/light/effects/NoiseEffect.png and /dev/null differ diff --git a/docs/assets/light/effects/ParticlesEffect.png b/docs/assets/light/effects/ParticlesEffect.png index 1227ac82..a572bc4b 100644 Binary files a/docs/assets/light/effects/ParticlesEffect.png and b/docs/assets/light/effects/ParticlesEffect.png differ diff --git a/docs/assets/light/effects/PlasmaEffect.png b/docs/assets/light/effects/PlasmaEffect.png deleted file mode 100644 index 0b7cd2b6..00000000 Binary files a/docs/assets/light/effects/PlasmaEffect.png and /dev/null differ diff --git a/docs/assets/light/effects/RainbowEffect.png b/docs/assets/light/effects/RainbowEffect.png index 29af3f24..3b03d08d 100644 Binary files a/docs/assets/light/effects/RainbowEffect.png and b/docs/assets/light/effects/RainbowEffect.png differ diff --git a/docs/assets/light/effects/RingsEffect.png b/docs/assets/light/effects/RingsEffect.png deleted file mode 100644 index 046db84b..00000000 Binary files a/docs/assets/light/effects/RingsEffect.png and /dev/null differ diff --git a/docs/assets/light/effects/RipplesEffect.png b/docs/assets/light/effects/RipplesEffect.png deleted file mode 100644 index b721a100..00000000 Binary files a/docs/assets/light/effects/RipplesEffect.png and /dev/null differ diff --git a/docs/assets/light/effects/SpiralEffect.png b/docs/assets/light/effects/SpiralEffect.png index b564f66d..70153a18 100644 Binary files a/docs/assets/light/effects/SpiralEffect.png and b/docs/assets/light/effects/SpiralEffect.png differ diff --git a/docs/assets/light/layouts/GridLayout.png b/docs/assets/light/layouts/GridLayout.png index 2d4458dd..8080398b 100644 Binary files a/docs/assets/light/layouts/GridLayout.png and b/docs/assets/light/layouts/GridLayout.png differ diff --git a/docs/assets/light/modifiers/CheckerboardModifier.png b/docs/assets/light/modifiers/CheckerboardModifier.png deleted file mode 100644 index 187744b2..00000000 Binary files a/docs/assets/light/modifiers/CheckerboardModifier.png and /dev/null differ diff --git a/docs/assets/light/modifiers/MirrorModifier.png b/docs/assets/light/modifiers/MirrorModifier.png deleted file mode 100644 index 679654bf..00000000 Binary files a/docs/assets/light/modifiers/MirrorModifier.png and /dev/null differ diff --git a/docs/assets/light/modifiers/MultiplyModifier.png b/docs/assets/light/modifiers/MultiplyModifier.png index 9919da1d..92830059 100644 Binary files a/docs/assets/light/modifiers/MultiplyModifier.png and b/docs/assets/light/modifiers/MultiplyModifier.png differ diff --git a/docs/assets/moondeck.gif b/docs/assets/moondeck.gif deleted file mode 100644 index f34f3be6..00000000 Binary files a/docs/assets/moondeck.gif and /dev/null differ diff --git a/docs/assets/ui/moondeck_esp32.png b/docs/assets/ui/moondeck_esp32.png index e3eef6fb..53856d75 100644 Binary files a/docs/assets/ui/moondeck_esp32.png and b/docs/assets/ui/moondeck_esp32.png differ diff --git a/docs/assets/ui/moondeck_live.png b/docs/assets/ui/moondeck_live.png index 02509ec1..97893d81 100644 Binary files a/docs/assets/ui/moondeck_live.png and b/docs/assets/ui/moondeck_live.png differ diff --git a/docs/assets/ui/moondeck_pc.png b/docs/assets/ui/moondeck_pc.png index e05aa01f..fc64f7da 100644 Binary files a/docs/assets/ui/moondeck_pc.png and b/docs/assets/ui/moondeck_pc.png differ diff --git a/docs/assets/ui/ui_light.png b/docs/assets/ui/ui_light.png deleted file mode 100644 index f088a8a1..00000000 Binary files a/docs/assets/ui/ui_light.png and /dev/null differ diff --git a/docs/assets/ui/ui_overview.png b/docs/assets/ui/ui_overview.png index 9941b8d5..74b924a9 100644 Binary files a/docs/assets/ui/ui_overview.png and b/docs/assets/ui/ui_overview.png differ diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 65be5733..826f1b3a 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -23,7 +23,7 @@ A map of everything in the three files, by theme. - **Distribution** — remaining platforms (Linux, Teensy, RPi), code-signing (macOS/Windows), live RMII Ethernet reconfigure, installer UX polish, P4 DHCP-hostname recheck, S31 web-flash (waiting on esptool-js); DevicesModule interop growth (more plugins, the command half, live peer state). - **ESP32 performance & memory** — E1.31 multicast (IGMP), WiFi ArtNet perf matrix, async ArtNet send (PSRAM-only), network round-trip drop/reorder test, slow eth bring-up, non-PSRAM memory ceiling + boot-time buffer degradation, task core-pinning; ops: static IP on STA, MoonDeck doc-asset hardening, CI SHA-pinning. - **Architecture** — disable-releases-resources, cross-module pin-uniqueness check, Improv-child-of-NetworkModule, `std::span` platform API, Improv-as-REST follow-ups, **live scripting** (on-device authored effects/layouts/modifiers/drivers/sensor logic — design phase, see the bottom-up survey); composition/config: runtime board presets, per-layout coordinate offset. -- **HTTP & OTA** — direct binary-upload OTA, HTTP file serving off the render tick. +- **HTTP & OTA** — HTTP file serving off the render tick; generic control + state topics over MQTT (the automation escape hatch beside the semantic HomeKit surface). - **Testing** — additional coverage (UI load time, teardown memory, JS harness), live full-suite state leak. - **Housekeeping** — WS-send socket-pair fixture, ESP-IDF version pinning, three-level device model, persistence-overlay audit, **ESP32-P4 rounds 3-4 (in progress)**, WiFi runtime disable. - **UI** — deferred-to-1.x items, open design questions (multi-layer UI, modifier-chain viz, presets, node-graph), and the v1 gap analysis. @@ -42,7 +42,7 @@ A map of everything in the three files, by theme. ## In-flight draft specs -A spec for a not-yet-built module can live here as a plain draft `.md` (alongside the design studies below) until the module ships — at which point its final spec is written under `../moonmodules/` (e.g. [drivers](../moonmodules/light/drivers/drivers.md)) and the draft is deleted. None are in flight right now. +A spec for a not-yet-built module can live here as a plain draft `.md` (alongside the design studies below) until the module ships — at which point its final spec is written under `../moonmodules/` (e.g. [drivers](../moonmodules/light/drivers.md)) and the draft is deleted. None are in flight right now. ## Design studies diff --git a/docs/backlog/moonlight_images/moonlight/drivers/Art-Net-In.png b/docs/backlog/archive_images/moonlight/drivers/Art-Net-In.png similarity index 100% rename from docs/backlog/moonlight_images/moonlight/drivers/Art-Net-In.png rename to docs/backlog/archive_images/moonlight/drivers/Art-Net-In.png diff --git a/docs/backlog/moonlight_images/moonlight/drivers/ArtNetInControls.png b/docs/backlog/archive_images/moonlight/drivers/ArtNetInControls.png similarity index 100% rename from docs/backlog/moonlight_images/moonlight/drivers/ArtNetInControls.png rename to docs/backlog/archive_images/moonlight/drivers/ArtNetInControls.png diff --git a/docs/backlog/moonlight_images/moonlight/drivers/ArtNetOutControls.png b/docs/backlog/archive_images/moonlight/drivers/ArtNetOutControls.png similarity index 100% rename from docs/backlog/moonlight_images/moonlight/drivers/ArtNetOutControls.png rename to docs/backlog/archive_images/moonlight/drivers/ArtNetOutControls.png diff --git a/docs/backlog/moonlight_images/moonlight/drivers/FastLED-Audio.png b/docs/backlog/archive_images/moonlight/drivers/FastLED-Audio.png similarity index 100% rename from docs/backlog/moonlight_images/moonlight/drivers/FastLED-Audio.png rename to docs/backlog/archive_images/moonlight/drivers/FastLED-Audio.png diff --git a/docs/backlog/moonlight_images/moonlight/drivers/FastLED-Driver.png b/docs/backlog/archive_images/moonlight/drivers/FastLED-Driver.png similarity index 100% rename from docs/backlog/moonlight_images/moonlight/drivers/FastLED-Driver.png rename to docs/backlog/archive_images/moonlight/drivers/FastLED-Driver.png diff --git a/docs/backlog/moonlight_images/moonlight/drivers/IMUDriverControls.png b/docs/backlog/archive_images/moonlight/drivers/IMUDriverControls.png similarity index 100% rename from docs/backlog/moonlight_images/moonlight/drivers/IMUDriverControls.png rename to docs/backlog/archive_images/moonlight/drivers/IMUDriverControls.png diff --git a/docs/backlog/moonlight_images/moonlight/drivers/MPU-6050.jpg b/docs/backlog/archive_images/moonlight/drivers/MPU-6050.jpg similarity index 100% rename from docs/backlog/moonlight_images/moonlight/drivers/MPU-6050.jpg rename to docs/backlog/archive_images/moonlight/drivers/MPU-6050.jpg diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Ball2D.gif b/docs/backlog/archive_images/moonlight/effects/E_Ball2D.gif similarity index 100% rename from docs/backlog/moonlight_images/moonlight/effects/E_Ball2D.gif rename to docs/backlog/archive_images/moonlight/effects/E_Ball2D.gif diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Cosmic3D.gif b/docs/backlog/archive_images/moonlight/effects/E_Cosmic3D.gif similarity index 100% rename from docs/backlog/moonlight_images/moonlight/effects/E_Cosmic3D.gif rename to docs/backlog/archive_images/moonlight/effects/E_Cosmic3D.gif diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Geq2D.gif b/docs/backlog/archive_images/moonlight/effects/E_Geq2D.gif similarity index 100% rename from docs/backlog/moonlight_images/moonlight/effects/E_Geq2D.gif rename to docs/backlog/archive_images/moonlight/effects/E_Geq2D.gif diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Hello1D.gif b/docs/backlog/archive_images/moonlight/effects/E_Hello1D.gif similarity index 100% rename from docs/backlog/moonlight_images/moonlight/effects/E_Hello1D.gif rename to docs/backlog/archive_images/moonlight/effects/E_Hello1D.gif diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Noise2D.gif b/docs/backlog/archive_images/moonlight/effects/E_Noise2D.gif similarity index 100% rename from docs/backlog/moonlight_images/moonlight/effects/E_Noise2D.gif rename to docs/backlog/archive_images/moonlight/effects/E_Noise2D.gif diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Noise3D.gif b/docs/backlog/archive_images/moonlight/effects/E_Noise3D.gif similarity index 100% rename from docs/backlog/moonlight_images/moonlight/effects/E_Noise3D.gif rename to docs/backlog/archive_images/moonlight/effects/E_Noise3D.gif diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Orbit2D.gif b/docs/backlog/archive_images/moonlight/effects/E_Orbit2D.gif similarity index 100% rename from docs/backlog/moonlight_images/moonlight/effects/E_Orbit2D.gif rename to docs/backlog/archive_images/moonlight/effects/E_Orbit2D.gif diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Oscillate2D.gif b/docs/backlog/archive_images/moonlight/effects/E_Oscillate2D.gif similarity index 100% rename from docs/backlog/moonlight_images/moonlight/effects/E_Oscillate2D.gif rename to docs/backlog/archive_images/moonlight/effects/E_Oscillate2D.gif diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Random1D.gif b/docs/backlog/archive_images/moonlight/effects/E_Random1D.gif similarity index 100% rename from docs/backlog/moonlight_images/moonlight/effects/E_Random1D.gif rename to docs/backlog/archive_images/moonlight/effects/E_Random1D.gif diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Sweep2D.gif b/docs/backlog/archive_images/moonlight/effects/E_Sweep2D.gif similarity index 100% rename from docs/backlog/moonlight_images/moonlight/effects/E_Sweep2D.gif rename to docs/backlog/archive_images/moonlight/effects/E_Sweep2D.gif diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Vu1D.gif b/docs/backlog/archive_images/moonlight/effects/E_Vu1D.gif similarity index 100% rename from docs/backlog/moonlight_images/moonlight/effects/E_Vu1D.gif rename to docs/backlog/archive_images/moonlight/effects/E_Vu1D.gif diff --git a/docs/backlog/moonlight_images/moonlight/effects/layers.gif b/docs/backlog/archive_images/moonlight/effects/layers.gif similarity index 100% rename from docs/backlog/moonlight_images/moonlight/effects/layers.gif rename to docs/backlog/archive_images/moonlight/effects/layers.gif diff --git a/docs/backlog/audio-dsp-roadmap.md b/docs/backlog/audio-dsp-roadmap.md new file mode 100644 index 00000000..315a92aa --- /dev/null +++ b/docs/backlog/audio-dsp-roadmap.md @@ -0,0 +1,119 @@ +# Audio DSP roadmap — source-seam extensions + adaptive noise gate (design study) + +> Forward-looking design study (backlog, present-tense-exempt). Migrated from the retired +> `AudioModule.md` archive page. The *shipped* audio path is documented present-tense in +> `src/core/AudioModule.h`'s `///` (and its generated moxygen page); this study holds the +> **prior-art analysis** and the **not-yet-built** extensions the module's `///` credit points at. + +## Prior art studied (credit by name) + +Audio-reactive lighting is a long-standing idea in the LED-controller world (WLED-MM and MoonLight +are the closest lineage). projectMM's audio path is its own implementation, designed from the INMP441 +datasheet and standard DSP — not traced from any one project — but three people's thinking is studied +here with respect and credited by name (the *Industry standards, our own code* principle: study hard, +write fresh). + +**Frank (softhack007)** — main author of the WLED-MM audioreactive usermod (the most-used open-source +audio-reactive LED implementation), a direct ancestor of the ideas this module learns from. The +product owner worked alongside Frank for years on WLED-SR / WLED-MM before MoonLight and projectMM. +His concept is the worked example in the *Adaptive noise gate* section below: his idea, our analysis, +written fresh against our architecture. + +**Troy (troyhacks)** — MoonModules team; keeps a WLED-MM fork where he reworked the audioreactive DSP +onto Espressif's **esp-dsp** library ("stupid fast compared to ArduinoFFT"), very low latency on S3/P4. +His contribution has two parts: +- *esp-dsp FFT.* Troy uses esp-dsp's **radix-4** real FFT (`dsps_fft4r_fc32`) with a Blackman-Harris + window. This validates the path projectMM is already on — **we use esp-dsp too**, the **radix-2** + float real FFT (`dsps_fft2r_fc32`) in `platform_esp32_i2s.cpp`. Same library; the one open + optimisation is **radix-4 vs radix-2** (fewer butterfly stages, log₄N vs log₂N — a measure-then- + maybe tune-up, not a gap; today the float FFT on the FPU is well inside one tick). Two adjacent, + *not-yet-adopted* options for the record: (a) esp-dsp's **int16 / fixed-point** path uses the + **built-in FFT instructions on S3/P4** — the lever for low-power FPU-less chips (C3/S2); we run + float today because our targets have an FPU; (b) Espressif's standalone **`dl_fft`** component does + *only* FFT without esp-dsp's shared twiddle tables — the right pick if a future build wants the FFT + without the rest of esp-dsp (we take the whole esp-dsp because we also want its DSP primitives). +- *Biquad pre-filters.* Before the FFT, Troy runs the samples through **biquad** HP/LP/peaking filters + via esp-dsp's `dsps_biquad_f32`, coefficients designed in EarLevel's Biquad Calculator v2. Squarely + industry-standard (the biquad / second-order section is *the* canonical EQ building block; the Audio + EQ Cookbook is the reference). Our pipeline does one fixed DC-blocker HP (~40 Hz); Troy's work shows + the next step — a **configurable biquad chain** (HP to kill rumble, LP to tame aliasing, optional + peaking to lift the mids the FFT under-reports). **Assessment: the biquad pre-filter chain is the + higher-value idea to adopt** (improves spectral accuracy with off-the-shelf primitives), and it + composes cleanly with the adaptive gate below (a learned gate on a cleanly-filtered signal beats one + on a raw signal). + +**Damian Schneider (DedeHai)** — WLED core dev; WLED's audioreactive usermod carries an integer / +fixed-point FFT path (~1.5 ms on a C3, >10× ArduinoFFT on FPU-less chips). The consensus (Troy + Frank) +is that with esp-dsp FFT + biquads, **fixed-point is not necessary on FPU chips** (S3/P4) — projectMM's +exact position: float on FPU targets, the int16 / `dl_fft` hardware path reserved for low-power chips. +DedeHai's current audio experiment is a PoC MSGEQ7-based path (offloading the spectrum to a dedicated +analyser chip) — a different point in the same space, noted for completeness. + +## Source-seam extensions (widen what feeds the pipeline) + +All of the following widen the **source seam** — what feeds the pipeline — leaving the DC-blocker / RMS +/ FFT / band analysis untouched. In roughly increasing hardware complexity: + +- **I²S with MCLK, for line-in — largely SHIPPED.** The INMP441 is self-clocked (no MCLK); a line-in + codec needs a master clock the ESP32 drives. The module already carries an `mclkPin` control (the + I²S peripheral drives MCLK for a line-in ADC; a codec board uses the codec's own MCLK). Remaining + source-seam work is the other three types below. +- **PDM mics** — a different I²S sub-mode (the IDF `i2s_pdm` driver), a variant behind the same + platform read. +- **Analog line-in.** DedeHai got analog input working on the S3; Troy got it working in ParrotRadio. + Troy's testing-confidence nuance worth recording: he considers his ParrotRadio analog path + better-exercised (he actually recorded + played back through it), whereas an unlistened-to analog + path "may not be as accurate as it looks." **If projectMM adopts analog line-in, validate by + listening**, not just by watching the level meter. +- **I²C-configured codecs (e.g. ES8311).** Do **not** hand-roll each codec's register config — pull in + Espressif's **`esp_codec_dev`** component (carries option tables for many codecs), supporting "a + bunch more codecs for free." The *Industry standards, our own code* call applied to codec bring-up. + +Troy also has **DSP boards** (I²S front-ends "way beyond regular codecs") — recorded so the line-in / +codec work leaves room for that class of source. + +## Adaptive noise gate (softhack007's concept, our analysis) + +Replace the borrowed `squelch`/`noiseFloor` knob ("a WLED-SR workaround, not a real gate") with a +proper adaptive noise gate. From softhack007 (granted permission to analyse); the assessment is ours. + +**The concept:** a standard [noise gate](https://en.wikipedia.org/wiki/Noise_gate) (below a threshold +the signal is silenced, above it passes), **asymmetric bang-bang timing** (open fast, close slow; +hysteresis avoids threshold chatter), driven by a **new "detect silence" function** (the explicitly +unfinished part). Leave the GEQ/FFT bands untouched (the gate acts on the time-domain signal). The +closing pre-condition should be **relative** ("percentage of average signal"), not an absolute count. +Optionally feed the gate **compressed samples** (sqrt/log) so the threshold behaves perceptually. + +**Five design constraints (the load-bearing part):** (1) samples are signed, arbitrary magnitude — +scaling to an effect range is AGC's job, not the gate's; (2) every `abs()` must be justified (a +rectify discards sign/phase); (3) prefer relative factors to absolute thresholds (the one allowed +absolute: changes < 2 counts are sampling noise); (4) smooth before thresholding; (5) every filter +adds delay — total audio delay must stay < 30 ms. + +**Verdict: yes, directionally — it's squarely industry-standard** (a hysteresis gate with +fast-attack/slow-release is how studio gates, radio squelch, and voice-activity detectors all work), +and moves us off the borrowed `squelch` constant. The relative-threshold insight (constraint 3) is the +valuable core: a gate keyed to a *learned* floor self-calibrates to whatever source is connected. +**Two cautions:** (1) timing is tight — a 512-sample block at 22050 Hz is already ~23 ms, leaving +< ~7 ms of the 30 ms budget; any smoothing must be cheap (one-pole) and proven on hardware; (2) it +overlaps the already-scoped per-band floor, so decompose and adopt in steps, don't overhaul. + +**Per-band floor overlap:** the backlogged per-band noise floor learns each band's idle baseline +(frequency-domain floor — kills a steady tone like the bench's ~258 Hz hum); the proposed gate answers +"is there any sound at all" (time-domain floor). Complementary halves, not competitors. + +**Decomposition (cherry-pick, most value early):** +1. **Per-band noise floor (already backlogged)** — ship first; frequency-domain half, smallest change, + kills the concrete hum. Independent. +2. **Relative thresholds reusing the RMS we already compute** — `computeLevel` already produces a + per-block RMS (an envelope estimate, and the one justified `abs()` under constraint 2). A + learned-floor follower over that RMS with open/close as *factors* of it is a small, host-testable + addition needing no new DSP stage and no extra delay. **The cherry to pick.** +3. **Hysteresis + asymmetric timing** — two time-constants on that follower plus a close-hold; where + the < 30 ms budget gets measured for real. +4. **Defer until proven:** log/dB-domain thresholds (downstream `magToByte` already compresses + perceptually), a true soft gate (0..1 gain vs hard 0/1). + +**Eventually retires:** the `floor` knob's hard-squelch role — `floor` becomes the *display* +noise-floor only (the dB-window bottom in `magToByte`), while the learned gate decides "is there +sound." A clean subtraction, but the *end* of the path, not the first step. diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index 475da5ef..6ff009f9 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -25,7 +25,7 @@ Forward-looking to-build items for the **core / infrastructure** domain (`src/co DevicesModule discovers via **passive UDP presence** (UDP 65506) feeding a [`DevicePlugin`](../../src/core/DevicePlugin.h) seam (shipped: projectMM + WLED plugins). mDNS is advertise-only so projectMM appears in the native WLED apps + Home Assistant; the WLED-app interop (list + live colour + brightness control) is shipped too. What remains is *growth on the seam*, each piece additive (one plugin file, no core change): -- **More discovery plugins** — ESPHome, Tasmota, Hue (*hub-shaped*: a bridge whose Zigbee bulbs are children behind it, with link-button auth). Each is a new `DevicePlugin` declaring its `discoveryPort()` + classifying the datagram (or, for a system that only does mDNS, a re-introduced advertise-side browse scoped to *foreign* services only — never the ones we advertise). Hue is the canonical "more than a flat device" case the seam is shaped for. (Note: Hue *control* already ships as an **output driver**, see [HueDriver](../moonmodules/light/drivers/HueDriver.md) — bulbs as effect pixels; the driver also *lists* its bridge in DevicesModule with the colour-light count. Two complementary follow-ups remain: (a) auto-fill the driver's bridge IP from discovery so the user doesn't type it (the mDNS-browse plugin above); (b) **pair once, not per driver** — pairing + the app key currently live on each HueDriver, so two drivers on one bridge pair twice. The clean end-state moves the bridge identity (IP + key + Pair button + light list) into DevicesModule and makes HueDriver a pure output that reads the paired bridge by IP — do this together with the discovery plugin, since both hinge on DevicesModule owning the bridge.) +- **More discovery plugins** — ESPHome, Tasmota, Hue (*hub-shaped*: a bridge whose Zigbee bulbs are children behind it, with link-button auth). Each is a new `DevicePlugin` declaring its `discoveryPort()` + classifying the datagram (or, for a system that only does mDNS, a re-introduced advertise-side browse scoped to *foreign* services only — never the ones we advertise). Hue is the canonical "more than a flat device" case the seam is shaped for. (Note: Hue *control* already ships as an **output driver**, see [HueDriver](../moonmodules/light/moxygen/HueDriver.md) — bulbs as effect pixels; the driver also *lists* its bridge in DevicesModule with the colour-light count. Two complementary follow-ups remain: (a) auto-fill the driver's bridge IP from discovery so the user doesn't type it (the mDNS-browse plugin above); (b) **pair once, not per driver** — pairing + the app key currently live on each HueDriver, so two drivers on one bridge pair twice. The clean end-state moves the bridge identity (IP + key + Pair button + light list) into DevicesModule and makes HueDriver a pure output that reads the paired bridge by IP — do this together with the discovery plugin, since both hinge on DevicesModule owning the bridge.) - **The command half** — `DevicePlugin::command()` (+ per-plugin capability/auth), so projectMM can *control* a discovered foreign device, not just list it: set WLED brightness via its JSON API, a Hue resource via the bridge's authenticated CLIP API, a Tasmota via `cmnd`. Built when a control consumer exists; the discovery seam is already shaped to accept it (incl. hub plugins). This is the **multi-ecosystem selling point** — one UI controlling WLED + ESPHome + Hue. Commands split by need (the rule, not "all REST"): must-arrive config over REST; latency-critical sync over UDP (~0.5–1 ms vs REST's 10–50 ms — REST would visibly de-sync). - **Live peer state** — a discovered peer's brightness / on-off shown in our list, refreshed by polling its REST `/json` after discovery gives the IP (discovery = UDP/mDNS, state = REST). The read-side complement to the command half. - **Non-IP transports (board-gated, far future)** — Tasmota-MQTT / zigbee2mqtt need an MQTT client; **direct Zigbee/Thread** (S31/C6/H2 802.15.4 radio) makes projectMM the *hub itself*, driving bulbs over the mesh with no gateway — the standout differentiator, the biggest lift. Same plugin philosophy, a transport addition + board gate. @@ -83,7 +83,7 @@ The real fix is a **dedicated send task**: `loop()` snapshots the corrected fram - A double-buffer (so the task reads frame N while render writes N+1) doubles it to ~96 KB — even more out of reach. - At 64×64 the frame is only 12 KB and *might* fit, but at 64×64 the synchronous send is already fast enough that ArtNet isn't the bottleneck — so the task buys nothing where it's affordable on no-PSRAM. -So the PSRAM gate isn't conservative; it's a hard requirement. PSRAM boards (S3/S2, Olimex-with-PSRAM variants) have megabytes for the handoff buffer via `heap_caps_malloc(..., MALLOC_CAP_SPIRAM)`; non-PSRAM boards keep the synchronous send and the documented "use Ethernet / smaller grid for high FPS at large grids" guidance ([NetworkSendDriver.md](../moonmodules/light/drivers/NetworkSendDriver.md)). +So the PSRAM gate isn't conservative; it's a hard requirement. PSRAM boards (S3/S2, Olimex-with-PSRAM variants) have megabytes for the handoff buffer via `heap_caps_malloc(..., MALLOC_CAP_SPIRAM)`; non-PSRAM boards keep the synchronous send and the documented "use Ethernet / smaller grid for high FPS at large grids" guidance ([NetworkSendDriver.md](../moonmodules/light/moxygen/NetworkSendDriver.md)). When implemented: `if constexpr (platform::hasPsram)` (or a runtime `hasPsram()` check) selects the async path; the buffer lives in PSRAM; the send task pins to the core opposite the render task (see [Task core-pinning](#task-core-pinning-backlog)). Non-PSRAM keeps `loop()`'s inline send unchanged. One handoff buffer + a binary semaphore/notification is the minimal shape — don't build a ring of frames until a second consumer needs it. @@ -281,6 +281,29 @@ What to build (~4 h): `HttpServerModule::handleConnection()` serves large embedded files (`app.js`, `style.css`) with the blocking `TcpConnection::write` — a page load can briefly stall `loop20ms`. One-shot per load (lower priority than the per-tick preview issue, which is fixed). Fix: serve large HTTP responses with `writeChunks` (the same non-blocking path used for preview frames). +### Generic control + state topics over MQTT — the automation escape hatch (backlog) + +The MQTT bridge ships a **semantic** topic surface (`<prefix>/on/set`, `brightness/set`, `hsv/set` → the `Drivers` controls) sized for Homebridge/HomeKit, which need fixed, typed topics per accessory. That leaves everything else the web UI can reach — effect choice, layout, modifier params, palette-by-index — unreachable from a home hub. The ask that surfaced this: *"expose the whole REST API over MQTT."* + +**It's compatible with Homebridge/HA, because it's a second topic namespace, not a replacement.** Homebridge only ever subscribes to the semantic topics it's configured for; a generic topic family sits beside them and a broker carries both. The two surfaces already share one apply-core — `Scheduler::setControl(module, control, json)`, the same seam `/api/control` and IR funnel through — so a generic MQTT surface is that core under a different topic shape, honouring the module's own "MQTT is a transport, not new control logic" contract. + +But "the whole REST API" over-scopes: of the routes (`/api/control`, `/api/state`, `/api/system`, `/api/modules[/…]`, `/api/dir`, `/api/file`, `/api/firmware/*`, `/api/reboot`, `/api/types`, the `/json/*` shim, `/ws`), only two are genuinely pub/sub-shaped. The rest are request/response or bulk transfer and belong on REST: + +- **`<prefix>/api/control/set`** ← `{"module","control","value"}` (the exact JSON `/api/control` takes) → `applySetControl`. The real win: any control on any module — the effect/layout/modifier/palette reach HomeKit can't express. The apply-core already validates (range / read-only / module-not-found via `SetControlResult`), so safety is free. +- **`<prefix>/api/state/get`** → publishes the `/api/state` JSON (retained). For dashboards (fps / heap / tick / current effect across a fleet), offline detection, and alerting — the monitoring half. + +Explicitly **out** (no practical MQTT case, and wrong for the transport): +- **`/api/file`, `/api/dir`, `/api/firmware/upload`** — kilobytes-to-megabytes of binary; MQTT is a small-message bus, REST already streams these correctly. +- **`/api/modules` add/delete/move/replace** — stateful, order-sensitive pipeline surgery a human does once in the UI; an automation reshaping the pipeline is an anti-pattern (the *Robust to any input* guarantee makes it *safe*, not *advisable*). +- **`/api/reboot`** — one narrow action; a single fleet-reboot topic is a mild nice-to-have, not "the API." +- **`/api/types`, `/json/*`, `/`, `/ws`, `/api/firmware/url`** — discovery metadata, the WLED shim, the UI itself, the socket: static, already-served-elsewhere, or a different protocol. + +**Shape:** ~15 lines routing into the existing `applySetControl` + a state publish; gate behind a `generic API` bool defaulting **off**, so a home user's broker isn't a remote-config backdoor and the curated HomeKit surface stays the clean default. **Don't** auto-generate a semantic topic per control — HomeKit/HA need stable typed topics; a generic `palette/set` whose meaning shifts per firmware breaks their discovery. Keep semantic topics hand-curated; let the generic pair be the escape hatch. + +**If the actual goal is deeper HA (not scripting), the better path is HA MQTT Discovery** — the device announces its controls as HA entities via retained `homeassistant/…/config` topics (the industry-standard pattern, *Common patterns first*), giving HA typed entities instead of a raw JSON pipe. Bigger than the generic pair, and the right lift if HA depth — not power-user scripting — is the target. Related integration threads to keep this coherent with: the [DevicesModule command half](#devicesmodule-interop-plugins-the-command-half-discovery-shipped) (Tasmota-MQTT / zigbee2mqtt as *outbound* control of foreign devices — the mirror of this *inbound* surface), and the [LightsControl integration point](backlog-mixed.md) (the eventual single owner of "device ↔ outside world", MQTT/HA included). + +**Open question — Homebridge example maps both `setBrightness` and `setHSV`.** In `homebridge-mqttthing`'s `lightbulb`, HSV's *value* (V) already carries HomeKit's Brightness characteristic, so mapping both topic pairs may double-drive brightness (mqttthing's docs lean toward using one or the other). The [MQTT § Homebridge example](../moonmodules/core/services.md#mqtt) currently lists both, to show the device's full topic surface. Resolve on hardware: flash a board, run Homebridge + mqttthing with that config, and check whether the Home-app brightness slider misbehaves — if it does, drop the two `brightness` topics from the example; if not, it's a non-issue. (Flagged by CodeRabbit; parked here rather than changing the doc on an untested hunch.) + ## Testing ### Additional test coverage (pending) @@ -356,7 +379,7 @@ Compile-time answer already ships: `--firmware esp32-eth` excludes the WiFi stac ## UI -Forward-looking companion to the shipped UI spec, [moonmodules/core/ui/ui.md](../moonmodules/core/ui/ui.md). The live spec describes the UI as shipped; this file holds what is **not** in it yet: deferred items, open design questions for 1.0, and the gap analysis against projectMM v1. The backward-looking half (how v1/v2 actually worked, patterns consciously rejected, recorded quirks) lives in [history/v1-inventory.md](../history/v1-inventory.md). +Forward-looking companion to the shipped UI spec, [moonmodules/core/services.md](../moonmodules/core/services.md). The live spec describes the UI as shipped; this file holds what is **not** in it yet: deferred items, open design questions for 1.0, and the gap analysis against projectMM v1. The backward-looking half (how v1/v2 actually worked, patterns consciously rejected, recorded quirks) lives in [history/v1-inventory.md](../history/v1-inventory.md). ### Deferred to 1.x @@ -368,7 +391,7 @@ Forward-looking companion to the shipped UI spec, [moonmodules/core/ui/ui.md](.. ### File Manager follow-ups -The shipped File Manager (see [ui.md](../moonmodules/core/ui/ui.md)) is a lazy expand/collapse tree over `/api/dir` + a size-capped text editor over `/api/file`, with drag-drop upload (tier 1) + per-file download + a filesystem-usage bar. Deferred capabilities, each self-contained: +The shipped File Manager (see [services.md](../moonmodules/core/services.md#file-manager)) is a lazy expand/collapse tree over `/api/dir` + a size-capped text editor over `/api/file`, with drag-drop upload (tier 1) + per-file download + a filesystem-usage bar. Deferred capabilities, each self-contained: - **Upload — recursive folder tier.** Single-file upload of **any size** streams to the file (`fsWriteStream`, binary-safe, `kUploadMax` + free-space guarded) and downloads stream back (`fsReadAt`), so text/config/binary all work. Remaining: **multi-file / recursive folder drops** — client-side `mkdir` + walk via `dataTransfer.items` `webkitGetAsEntry()`. - **Download — folder as `.zip`.** Per-file download streams (`<a download>` on `/api/file`). A folder download needs the browser to walk `/api/dir` recursively, fetch each file, and build a `.zip` client-side — which means bundling a zip library into `app.js`. That's a permanent app.js size bump, and app.js is embedded in firmware, so it weighs on the flash budget (see the flash-budget item above). Gate on real demand; symmetric with the folder-upload tier. diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md index 553254c3..64b6d32d 100644 --- a/docs/backlog/backlog-light.md +++ b/docs/backlog/backlog-light.md @@ -14,7 +14,7 @@ The LcdLedDriver (S3 LCD_CAM i80) and ParlioLedDriver (P4 Parlio) share ~245 of ### Classic ESP32 I2S 16-lane parallel LED driver (future) — beyond RMT's 8 channels -The **classic ESP32 has 8 RMT TX channels** (`platform_config.h`: "8 on classic ESP32, 4 on the S3 and P4"), so RMT covers up to 8 parallel outputs on classic ESP32 — e.g. the 8-output QuinLED Dig-Octa runs fine on `RmtLedDriver`. For **more than 8 lanes on classic ESP32**, the established trick drives the **I2S peripheral in LCD/parallel mode** (the hpwit [I2SClocklessLedDriver](https://github.com/hpwit/I2SClocklessLedDriver) / FastLED I2S lineage), clocking out up to **16 lanes** from one autonomous DMA transfer. This is the classic ESP32's high-lane-count path, distinct from the S3 (LCD_CAM → `LcdLedDriver`, plus the 1..8-pin LCD item above) and the P4 (Parlio). No catalog board needs it today (none exceeds 8 outputs), so no board's `planned` list points at it yet; it's the marker for a future ≥9-output classic-ESP32 board. Studied under *Industry standards, our own code* — carry the idea, write our own against the project architecture (host-testable encoder in `src/light/`, peripheral seam in `src/platform/esp32/`). **When it lands**, follow the per-chip driver-gating pattern now in `main.cpp` (each LED driver's `#include` + `registerType` is wrapped in `#if defined(CONFIG_SOC_<PERIPHERAL>_SUPPORTED)`, keyed off the SOC capability macro that backs its `platform_config.h` lane-count flag): the I2S driver gates on the relevant I2S/LCD SOC macro so it compiles + registers on classic ESP32 only, and adds an `i2sLanes` capability flag beside `rmtTxChannels`/`lcdLanes`/`parlioLanes`. Prior art: hpwit's I2SClockless lineage and FastLED's I2S driver; the same parallel-DMA lineage is already credited in [the LED-output card](../moonmodules/light/drivers/drivers.md#lcdled). +The **classic ESP32 has 8 RMT TX channels** (`platform_config.h`: "8 on classic ESP32, 4 on the S3 and P4"), so RMT covers up to 8 parallel outputs on classic ESP32 — e.g. the 8-output QuinLED Dig-Octa runs fine on `RmtLedDriver`. For **more than 8 lanes on classic ESP32**, the established trick drives the **I2S peripheral in LCD/parallel mode** (the hpwit [I2SClocklessLedDriver](https://github.com/hpwit/I2SClocklessLedDriver) / FastLED I2S lineage), clocking out up to **16 lanes** from one autonomous DMA transfer. This is the classic ESP32's high-lane-count path, distinct from the S3 (LCD_CAM → `LcdLedDriver`, plus the 1..8-pin LCD item above) and the P4 (Parlio). No catalog board needs it today (none exceeds 8 outputs), so no board's `planned` list points at it yet; it's the marker for a future ≥9-output classic-ESP32 board. Studied under *Industry standards, our own code* — carry the idea, write our own against the project architecture (host-testable encoder in `src/light/`, peripheral seam in `src/platform/esp32/`). **When it lands**, follow the per-chip driver-gating pattern now in `main.cpp` (each LED driver's `#include` + `registerType` is wrapped in `#if defined(CONFIG_SOC_<PERIPHERAL>_SUPPORTED)`, keyed off the SOC capability macro that backs its `platform_config.h` lane-count flag): the I2S driver gates on the relevant I2S/LCD SOC macro so it compiles + registers on classic ESP32 only, and adds an `i2sLanes` capability flag beside `rmtTxChannels`/`lcdLanes`/`parlioLanes`. Prior art: hpwit's I2SClockless lineage and FastLED's I2S driver; the same parallel-DMA lineage is already credited in [the LED-output card](../moonmodules/light/drivers.md#lcdled). ## Sensors and audio-reactive input @@ -131,7 +131,7 @@ For driving **lots of LEDs**, internal SRAM is the scarce resource and the paral ## LED drivers — deferred -The LED-driver increments **shipped**: increment 1 (RMT/WS2812B single-strand on classic ESP32 — [`RmtLedDriver.h`](../../src/light/drivers/RmtLedDriver.h), `RmtSymbol.h`, `platform_esp32_rmt.cpp`) and increment 2 (2a multi-pin RMT, 2b parallel LCD_CAM on the S3 — [`LcdLedDriver.h`](../../src/light/drivers/LcdLedDriver.h) via [`ParallelLedDriver.h`](../../src/light/drivers/ParallelLedDriver.h), `platform_esp32_lcd.cpp`), all with host + on-board-loopback tests, hardware-proven. The locked decisions, file-by-file phases, the WiFi-flicker test-rig analysis, and the bench deviations (8-GPIO i80 bus, 2.67 MHz slot clock, SOC-macro gate, real-frame loopback) are in [decisions.md](../history/decisions.md), the [driver docs](../moonmodules/light/drivers/RmtLedDriver.md), and the [analysis docs](leddriver-analysis-top-down.md). What remains here is only the work that has **not** shipped and is tracked nowhere else. +The LED-driver increments **shipped**: increment 1 (RMT/WS2812B single-strand on classic ESP32 — [`RmtLedDriver.h`](../../src/light/drivers/RmtLedDriver.h), `RmtSymbol.h`, `platform_esp32_rmt.cpp`) and increment 2 (2a multi-pin RMT, 2b parallel LCD_CAM on the S3 — [`LcdLedDriver.h`](../../src/light/drivers/LcdLedDriver.h) via [`ParallelLedDriver.h`](../../src/light/drivers/ParallelLedDriver.h), `platform_esp32_lcd.cpp`), all with host + on-board-loopback tests, hardware-proven. The locked decisions, file-by-file phases, the WiFi-flicker test-rig analysis, and the bench deviations (8-GPIO i80 bus, 2.67 MHz slot clock, SOC-macro gate, real-frame loopback) are in [decisions.md](../history/decisions.md), the [driver docs](../moonmodules/light/moxygen/RmtLedDriver.md), and the [analysis docs](leddriver-analysis-top-down.md). What remains here is only the work that has **not** shipped and is tracked nowhere else. - **sigrok/fx2lafw cross-check + MoonDeck "LED driver test" Python script** — the independent-clock proof and the run-from-MoonDeck flow ([analysis §5.3](leddriver-analysis-top-down.md)). The on-board RMT-RX loopback (shipped) is the cheap CI correctness gate but a *compromised witness* for WiFi-induced flicker — the RX capture runs on the same ESP32 whose WiFi causes the glitch. The real flicker test is a **sustained capture (seconds) with WiFi associated + a packet flood**, decoding every frame for a byte-slip or reset-gap deviation; it belongs with the core-1 driver-task work below, since that task pinning is the *fix* it validates. A DSLogic Plus (100 MS/s) upgrade is reactive — only if a flicker reproduces that 24 MS/s can't resolve. - **Dedicated core-1 driver task + per-module core-affinity control** ([analysis §7.2](leddriver-analysis-top-down.md)) — the WiFi-glitch mitigation, shared across all the LED drivers. (See also [backlog-core § Task core-pinning](backlog-core.md#task-core-pinning-backlog) for the general task-pinning question.) diff --git a/docs/backlog/docs-system-overhaul.md b/docs/backlog/docs-system-overhaul.md deleted file mode 100644 index 32a824a8..00000000 --- a/docs/backlog/docs-system-overhaul.md +++ /dev/null @@ -1,104 +0,0 @@ -# Documentation system overhaul — investigation + phased proposal - -Forward-looking design study (per CLAUDE.md, `docs/backlog/` is exempt from present-tense). Written in response to the product-owner brief: the docs are ~259K words across 19.5K lines of `.md`; end users can't navigate them, a small change touches source + tests + docs at once, and technical detail lives in `.md` rather than in the code. Goals: **no duplication**, a **top-down end-user path**, **easy developer drill-down into `.h`/`.cpp`**, and **tests visible to end users** — all hosted on GitHub Pages. - -Follows the *Refactor for simplicity* process rule: alternatives enumerated, gains/losses named, leanest option recommended, presented as a proposal — nothing moves until the PO picks. - -## What we have today (measured) - -- **~259K words / 19.5K lines** of `.md`. Biggest buckets: `docs/history/` (97K words, incl. 47 plan files), `docs/backlog/` (57K), `docs/moonmodules/` (38K), `docs/tests/` (25K, **auto-generated**), top-level `docs/` (29K). -- **GitHub Pages today publishes only the web installer** (`docs/install/`), *not the docs*. So the docs are read as raw `.md` on github.com — no nav, no search, no landing page. This is the root of "impossible for end users to read": **there is no doc *site* at all yet.** -- **One generation loop already works and proves the model:** `moondeck/docs/generate_test_docs.py` reads test metadata (`// @module` tags in `test/unit/*.cpp`, JSON fields in `test/scenarios/*.json`) and emits `docs/tests/{unit,scenario}-tests.md`. The same parser (`_test_metadata.py`) feeds MoonDeck's `/api/tests` UI. Source of truth = the test file. This is the pattern to extend, not replace. -- **Duplication hotspots** — each fact lives in N places, changing one forces the others: - - | Fact | Lives in | Sync today | - |---|---|---| - | Control name (`"sparking"`) | `.h` (definition) + `.md` spec + test code | `check_specs.py` checks *presence* in `.md`, not accuracy | - | Control range/default (8000/16000/22050/44100) | `.h` array + `.md` prose | none — hand-copied | - | Author/attribution | `.h` `// Author:` + `.md` `Origin:` (40+ effects, two formats) | none | - | Module name (`FireEffect`) | class + `registerType(...)` string + test `@module` + `.md` anchor + `deviceModels.json` | `check_specs`/`check_devices` partial | - | Architectural fact (buffer persists; AudioModule respects `enabled`) | `.h` comment + module `.md` + `architecture.md` | none | - -- **No structured doc-comments in source.** `.h`/`.cpp` carry rich *inline `//` rationale* but no Doxygen/`///` API blocks. So "move technical detail into the code" is a real move, not a relabel. - -## The core insight - -Two different problems wear the same "docs too big" coat, and they have **opposite** fixes: - -1. **Navigation / readability** (end users). Fix = a rendered site with a top-down nav and search. Additive: nothing is deleted, the raw `.md` gets a front door. **Cheap, immediate, low-risk.** -2. **Duplication** (developers). Fix = single-source each fact and *generate* the copies (or include them). Subtractive and invasive: it changes where facts live and how they're authored. **Do incrementally, proven per fact-type before rollout.** - -Phase them in that order: the site is the quick win that makes everything else visible; de-duplication is the slow structural win. Do **not** bundle them — a big-bang "new site + moved all facts into code" is the kind of change *Refactor for simplicity* exists to stop. - -## The tools (all GitHub-Pages-native) - -| Tool | Role | Why it fits here | -|---|---|---| -| **Material for MkDocs** | The site: nav tree, instant search, versioning. | PO-named default; the recognised standard (WLED-adjacent projects, FastLED-ecosystem, thousands of firmware projects use it). Ships to Pages via one CI job. `nav:` in `mkdocs.yml` *is* the top-down structure. | -| **`pymdownx.snippets`** (`--8<--`) | De-dup mechanism #1: pull real source lines into a doc. | Built into Material. A spec can embed the actual `controls_.addX(...)` block or an author comment *from the `.h`* — one source of truth, rendered in two places. No new tool. | -| **`mkdocs-gen-files` / `mkdocs-macros`** | De-dup mechanism #2: generate whole doc pages from data at build time. | Standard MkDocs plugins. Lets `generate_test_docs.py`-style generation run *inside* the site build instead of committing generated `.md`. Kills the "forgot to regenerate" drift. | -| **Doxide** | Developer drill-down: parse `.h`/`.cpp` with Tree-sitter → **Markdown** → rendered *in the same Material site*. | This is the ".h/.cpp viewer layered on top" the PO asked for. Unlike classic Doxygen (1998-era HTML, a separate ugly site), Doxide emits Markdown that lives in *our* nav, *our* search, *our* theme — developers drill from a module's user page straight into its annotated source, one site. | - -Rejected: **classic Doxygen HTML** (separate site, dated UI, not integrated), **Sphinx/Breathe** (Python-doc-shaped, heavier, C++ via Breathe is awkward), **Docusaurus/VitePress** (Node toolchain, no C++ story), **mdBook** (great but no C++ integration, weaker search). Material+Doxide is the least bespoke combination that hits all four goals on Pages. - -## Phased transition - -### Phase 0 — Stand up the site (no content changes) — *now, ~half a day* -- Add `mkdocs.yml` with Material, `pymdownx.snippets`, instant search. Author a `nav:` tree that imposes the **top-down end-user order** (see below) over the *existing* files — no file moves, no rewrites. -- Add a `build-docs` CI job that `mkdocs build`s and publishes to Pages **alongside** the installer (installer already owns `/install/`; docs take `/` or `/docs/`). -- Add a **landing page** (`docs/index.md`) — the front door end users currently lack: "what is this → install → first light → effects → drill down." -- **Outcome:** every existing word is now navigable + searchable, zero duplication introduced, zero risk. This alone solves the *"impossible for end users to read"* complaint. - -### Phase 1 — Restructure nav for the two audiences — *DONE (Phase 0's nav already delivered it)* -- Split the nav (not the files, yet) into **User guide** (README intro, getting-started, effect catalog, per-board install) and **Developer/Reference** (architecture, coding-standards, module specs, generated tests, source-drill). `history/` + `backlog/` stay out of the published nav (internal). -- Move the *most* end-user-hostile prose (deep architecture) below a "Developer" fold so a user's top-down path never hits it unless they drill. -- **Outcome:** the "top-to-down, easy navigate" structure, still additive. - -### Phase 2 — Fold generated tests into the site + surface them to users — *DONE (moondeck/docs/mkdocs_hooks.py)* - -*As shipped: the two inventory pages are generated at build time (never committed). Each effect/modifier keeps a compact card (heading, GIF, params, source link) with a one-line `[Tests]` link into its inventory section — an early attempt to expand that link into the full inline case list bloated the cards and was reverted; tests are a link, not a dump.* -- Run the existing test-doc generation *at site-build time* via `mkdocs-gen-files` (stop committing `docs/tests/*.md` — the 25K generated words leave the repo, generated fresh each build). Kills the "forgot to regenerate" drift class entirely. -- On **each effect/module user page**, auto-embed "Tests proving this works: …" from the same test metadata — so an end user reading about *Fire* sees the tests that pin it. This is the PO's *"github issues will be solved adding a new test to proof it, this should be visible to end users."* The link from issue → test → visible-on-the-module-page becomes the norm. -- **Outcome:** tests visible to users; 25K words of committed generation deleted (subtraction). - -### Phase 3 — De-duplicate facts, one fact-type at a time — *DONE, but pivoted (see below)* - -*As shipped: the snippet-include premise was wrong — `.h` and `.md` hold the same fact in different forms (code vs prose) for two audiences, which `--8<--` can't bridge. The real duplication was narrower: control **ranges** (~50) and author **URLs** (~51) restated in both places. PO chose **validate, not generate** — `check_specs.py` now flags when a doc's stated range/URL drifts from the `.h` (block-scoped on catalog pages; tolerant of human range spellings), pinned by `test/python/test_check_specs_drift.py`. Control *names* and architectural facts were confirmed NOT duplication (audience-aware) and left alone. Original text below for the record.* -Prove each on **one module**, then sweep. Order by leverage: -1. **Author/attribution** → single source in the `.h`, `--8<--` snippet-include into the `.md`. Deletes 40+ hand-maintained `Origin:` copies. (Lowest risk: it's a comment.) -2. **Control names + ranges/defaults** → the `.h` `controls_.addX(...)` block is already the source of truth; either snippet-include it, or extend `check_specs.py` into a *generator* that emits the control table into the spec. Deletes the hand-copied range prose; upgrades `check_specs` from "checks presence" to "owns the table." -3. **Cross-doc architectural facts** → pick the one true home (usually `architecture.md` or the `.h`), replace the other copies with a link/anchor per *Document a thing once, reference it generically* (already a principle — this enforces it mechanically). -- **Outcome:** "change a small thing, many files change" shrinks to "change the source, the copies regenerate." - -### Phase 4 — Developer drill-down into source — *split: 4a snippets DONE, 4b Doxide next commit* - -*As shipped (4a): Doxide can't use `uv` (a from-source C++ binary) and our 139 `.h` files use plain `//` comments, not Doxygen style — so it's heavy on CI + comment-conversion. PO wants Doxide eventually but chose to try `pymdownx.snippets` first (uv-native, no new tool): `--8<--` now embeds the real `ImprovFrameType` enum + Preview wire-format from the source `.h` into their docs — the source IS the doc, no drift. Doxide (4b) is spec'd for its own next commit (Plan-20260702 Docs Phase 4a), informed by what 4a showed about how our `//` comments render. Original Doxide text below.* -- Add **Doxide**: annotate `.h` public API with its lightweight comment style, generate Markdown into the Developer section of the site. Start with `core/` (the stable base), then light domain. -- This is where "technical details in the code, not `.md`" lands: the module `.md` shrinks to *wire contracts + cross-wiring + prior art* (its already-stated job per CLAUDE.md), and the *API-level* detail is generated from annotated source. -- **Outcome:** developers drill user-page → spec → annotated source, one site, search across all of it. - -## Alternatives considered (for the record) - -- **A — Site only, never de-dup.** Solves navigation, ignores the duplication complaint. Rejected: PO's *main* goal is no-duplication. -- **B — De-dup first, site later.** Restructures authoring before anyone can see the payoff; high risk, no visible win for weeks. Rejected: wrong order. -- **C — Big-bang Material + Doxide + full de-dup in one branch.** Everything at once. Rejected by *Refactor for simplicity*: unreviewable, all-or-nothing. -- **D (recommended) — Phased: site now (additive, safe), de-dup incrementally (proven per fact-type), Doxide last.** Each phase ships a visible win and is independently revertible. - -## Decisions locked (PO, 2026-07) - -- **Scope:** Phase 0 approved (stand up the site). Phases 1–4 each need a separate go-ahead. -- **Site URL:** docs at Pages root `/`; installer stays at `/install/`. The site is assembled in CI into a throwaway dir; the repo addition is `mkdocs.yml`. -- **`docs/` de-overloading (landed with Phase 0):** `docs/` had held three unlike things. The standalone web installer moved out to a top-level **`web-installer/`** (it's an app, not docs; deployed URL unchanged at `/install/`). The transient `history/` + `backlog/` stay in `docs/` but excluded from the site — they're compaction-bound, so relocating them is discarded churn. Result: `docs/` = published doc-site source + transient internal notes (excluded). -- **`scripts/` → `moondeck/`:** approved, but deferred to its own next commit (see `rename-scripts-to-moondeck.md`). -- **Doxide comment style:** approved as the source-annotation convention for Phase 4 (Doxygen-family, recognised standard). - -## Open questions for the PO - -1. **Site URL layout:** docs at Pages root `/` with installer under `/install/` (docs are the front door), or docs under `/docs/` keeping `/` as the installer landing? (Recommend: docs at `/`, a prominent "Install" button routing to `/install/`.) -2. **Doxide comment style** is a new per-`.h` convention — acceptable as a *recognised* standard (it's Doxygen-family), or do we want inline `//` to stay the only comment form? (Affects Phase 4 only.) -3. **Scope of Phase 0 approval:** stand up the site now (safe, additive) and defer 1–4 for separate go-aheads, or approve the whole arc? - -## Source - -- Investigation basis: `moondeck/docs/generate_test_docs.py`, `moondeck/check/check_specs.py`, `.github/workflows/release.yml` (Pages deploy), the `docs/` tree. -- Prior art / tooling: [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/), [pymdownx snippets](https://facelessuser.github.io/pymdown-extensions/extensions/snippets/), [Doxide](https://github.com/lawmurray/doxide). diff --git a/docs/backlog/folder-structure-proposal.md b/docs/backlog/folder-structure-proposal.md index 142dff84..2655b7c8 100644 --- a/docs/backlog/folder-structure-proposal.md +++ b/docs/backlog/folder-structure-proposal.md @@ -1,72 +1,43 @@ -# Folder-structure consistency — decision +# Folder-structure decision — library is a tag, not a folder -A *Refactor for simplicity* decision (per CLAUDE.md). Alternatives were weighed; the product owner chose. This records the chosen structure and the work to reach it. Nothing moves until each move is executed deliberately. +A *Refactor for simplicity* decision (per CLAUDE.md), recorded here because the live catalog pages +(`effects.md` / `modifiers.md` / `layouts.md`) cite it for *why* the tree is shaped the way it is. The +execution has shipped (assets + tests type-split; docs flattened to `domain/type` with catalog + +generated `moxygen/` pages); this is the surviving **rationale**, not a to-build list. -## The three axes — and where each one earns a place +## The three axes -1. **Domain** — `core` vs `light`. Already structured (src, docs, tests). -2. **Module type** — effects / modifiers / layouts / drivers. Structured in `src/light/` + `docs/moonmodules/light/`; **missing** in `test/` and `assets/` — added here. -3. **Library** — a module's *origin* (MoonLight, WLED, MoonModules, projectMM-native). **Used only as a doc split and a UI tag — NOT a folder axis** (decision below). +1. **Domain** — `core` vs `light`. Structured in `src/`, `docs/`, `test/`, `assets/`. +2. **Module type** — effects / modifiers / layouts / drivers. Structured in each of those areas. +3. **Library** — a module's *origin* (MoonLight, WLED, MoonModules, projectMM-native). **A tag and a + doc split — NOT a folder axis.** ## Decision: `domain / type` folders; library is a tag (+ a doc split) -The structure is **`<core|light> / <type> / Module`**, flat within type. Library does **not** become a folder level. - -**Why library is not a folder** (the deciding analysis): an effect's origin is frequently *blended*, not a single fact — e.g. `DistortionWavesEffect` cites MoonLight + WLED + v1 + v2; `GameOfLifeEffect` cites MoonLight + MoonModules + v1; several have no clear origin. A folder forces one answer to a multi-valued question, and a *wrong* or *shifting* answer means a multi-file move (src + assets + tests + the registered `.md` path). It also duplicates the dimension the `tags()` emoji already carries (and the emoji can carry *several* origins; a folder can't). **The end user does not care about a module's library** — they filter by the emoji chip in the UI if they want origin at all. So library stays where it's free and non-duplicative: - -- **In code:** the `tags()` emoji (already there; drives the UI origin-filter; can be multi-valued). -- **In docs:** the page split (below) — the one place library earns a structural role, because docs have an explosion problem src doesn't. - -This drops every drawback of library-as-folder (fuzzy-origin filing, two-places-disagree, reclassification churn, sparse subfolders, deep paths) at once. - -### The target tree - -```text -src/light/ - effects/ EffectBase.h, Rainbow.h, Wave.h, DistortionWaves.h, … (flat per type) - modifiers/ ModifierBase.h, Multiply.h, Rotate.h, … - layouts/ GridLayout.h, SphereLayout.h, … - drivers/ Drivers.h, Correction.h, RmtLedDriver.h, HueDriver.h, … -src/core/ … (unchanged — no type or library axis) -``` - -Identical shape for `docs/assets/` and `test/` (below). `src/` itself is **unchanged** — it's already `domain/type`, flat within type; library was never there as a folder and stays out. - -## Docs: per-library pages with compact rows (solves explosion *and* giant-file) - -Per-module `.md` would be ~65 files post-migration (explosion); one all-effects file would be ~2100 lines (MoonLight's mistake). The middle ground: **one page per library**, each effect a **compact table row**. - -```text -docs/moonmodules/light/ - effects_moonlight.md ← ~30 effects, one row each (~120-150 lines) - effects_wled.md ← ~20 - effects_projectmm.md ← ~15 - modifiers_<library>.md layouts_<library>.md drivers_<library>.md (where a library has them) -docs/moonmodules/core/ ← unchanged: per-module .md (stable count, no explosion) -``` - -- **Row format** (MoonLight-style): `| Name + tags | gif | one-line description | controls |`. Drops the per-module `Tests` / `Design notes` / `Source` sections — source is derivable from the name, tests are auto-discovered. ~4 lines/effect, so a 30-effect page is ~120 lines. -- **Why library splits docs but not src:** docs are the only area with the explosion problem, and a doc page is *forgiving* about fuzzy origin — a blended-lineage effect goes on one page with its full origin in the row's tags/prose; mis-filing is a one-line edit, not a multi-file move. So the drawbacks that made library-as-*folder* bad are soft for library-as-*doc-page*. -- **`check_specs.py` rewrite:** every registered module's control names must appear somewhere in its library page (preserves the anti-drift guarantee). The registered `.md` arg changes from `light/effects/Rainbow.md` to `light/effects_moonlight.md` (or `#rainbow`). This is migration **Stage 2** work. - -## assets + tests: add the missing type-split (mirror src) - -The consistency win that's independent of library — make `test/` and `assets/` match src's existing `domain/type` shape: - -```text -docs/assets/ - core/ DevicesModule.png, Drivers.png, … - light/ - effects/ Rainbow.gif, Wave.gif, DistortionWaves.gif, … - modifiers/ layouts/ drivers/ - boards/ gettingstarted/ ← kept as-is - -test/unit/light/ - effects/ unit_Rainbow.cpp, unit_Wave.cpp, … - modifiers/ layouts/ drivers/ -test/unit/core/ ← unchanged -test/scenarios/light/{effects,layouts,drivers,…}/ (mirror) -``` +The structure is **`<core|light> / <type> / Module`**, flat within type. Library does **not** become a +folder level. + +**Why library is not a folder** (the deciding analysis, still true): an effect's origin is frequently +*blended*, not a single fact — `DistortionWavesEffect` cites MoonLight + WLED + v1 + v2; +`GameOfLifeEffect` cites MoonLight + MoonModules + v1; several have no clear origin. A folder forces +one answer to a multi-valued question, and a *wrong* or *shifting* answer means a multi-file move +(src + assets + tests + the registered doc path). It also duplicates the dimension the `tags()` emoji +already carries (and the emoji can carry *several* origins; a folder can't). **The end user does not +care about a module's library** — they filter by the emoji chip in the UI if they want origin at all. +So library stays where it's free and non-duplicative: + +- **In code / assets / tests:** the `tags()` emoji (drives the UI origin-filter; can be multi-valued). + The leaf files are flat within their type folder (`src/light/effects/DistortionWaves.h`, + `docs/assets/light/effects/DistortionWaves.gif`, `test/unit/light/unit_DistortionWaves.cpp`). +- **In docs:** library rides in the **page** dimension, not a folder — a catalog page per type + (`effects.md`) with library *sections* inside today, splitting to per-library page *names* + (`effects_wled.md`) as a section outgrows its page. This is the one area with a doc-explosion + problem, and a doc page is *forgiving* about fuzzy origin (a blended-lineage effect goes on one page + with its full origin in the row's tags; mis-filing is a one-line edit, not a multi-file move). So + the drawbacks that make library-as-*folder* bad are soft for library-as-*doc-page*. + +This drops every drawback of library-as-folder (fuzzy-origin filing, two-places-disagree, +reclassification churn, sparse subfolders, deep paths) at once. ## The one rule, across all four areas @@ -75,25 +46,13 @@ test/scenarios/light/{effects,layouts,drivers,…}/ (mirror) | **src** | `light/` | `effects/` | `DistortionWaves.h` | tag in `tags()` | | **assets** | `light/` | `effects/` | `DistortionWaves.gif` | — | | **tests** | `light/` | `effects/` | `unit_DistortionWaves.cpp` | — | -| **docs** | `light/` | the type+library collapse into the page name → `effects_wled.md` | (row inside) | the page split | - -`docs` is the one area where `type` is expressed as part of a **page name** (`effects_<library>.md`) rather than a folder, because the migration compacts docs to per-library pages — and library, the only axis with an explosion problem, rides along in that name. Everywhere else: plain `domain/type` folders, library as a tag. - -## Impacted folders + the work - -| Folder | Change | Cost | When | -|---|---|---|---| -| `docs/assets/` | flat `screenshots/` → `assets/{core, light/{effects,drivers,layouts,modifiers}}/`; keep `boards/`, `gettingstarted/`. Move 63 files, update ~34 referencing docs. | Medium, mechanical | **Now** (clearly-broken flat area; PO specified the asset structure) | -| `test/unit/`, `test/scenarios/` | add the type-split under `core/`/`light/`; re-path the 81 CMake test entries. | Medium, mechanical | **Now or fold into next test-touching change** | -| `docs/moonmodules/` | per-library pages (`effects_<library>.md`) with compact rows + the `check_specs.py` rewrite; delete the ~21 per-module effect `.md`s. | Medium | **Migration Stage 2** (already planned) | -| `src/` | **unchanged** (already `domain/type`, flat). | none | — | - -**Not reshaped** (correctly orthogonal): `test/js`, `test/python` (host-side; test moondeck/installer, not modules), `src/platform/{desktop,esp32}` (platform split, already consistent), `src/{core,light}/moonlive` (feature sub-tree). +| **docs** | `light/` | the page name (`effects.md`, later `effects_<library>.md`) | (row inside) | the page split | -## Sequencing +`docs` is the one area where `type` is expressed as part of a **page name** rather than a folder, +because the docs compact to per-type/per-library pages — and library, the only axis with an explosion +problem, rides along in that name. Everywhere else: plain `domain/type` folders, library as a tag. -1. **Now:** `docs/assets/` reorg (the broken flat area). -2. **Now / next test change:** `test/` type-split. -3. **Migration Stage 2:** per-library doc pages (compact rows) + the `check_specs.py` rewrite + delete the per-module `.md`s. +## Still open (future growth, not blocking) -`src/` needs no move at all — the leanest possible outcome. +- **Per-library page split** (`effects_wled.md`, …) — a lift-not-rewrite when a library's section + outgrows its page; the flat page names + within-page sections are already in place for it. diff --git a/docs/backlog/livescripts-analysis-top-down.md b/docs/backlog/livescripts-analysis-top-down.md index aa977a39..2aa2dfdd 100644 --- a/docs/backlog/livescripts-analysis-top-down.md +++ b/docs/backlog/livescripts-analysis-top-down.md @@ -427,7 +427,7 @@ Credits also live in the bottom-up's *Prior art & credits* and the digest [histo ### Public credit — to lift into `docs/moonmodules/core/MoonLive.md` when the module spec is written -The credits above are the analysis's internal record. The block below is the **user-facing** version for the eventual `MoonLive.md` "Prior art" section. Drop it in when MoonLive ships; matches the house style of the other modules' Prior-art sections (e.g. AudioModule.md, [LcdLedDriver.md](../moonmodules/light/drivers/LcdLedDriver.md)). +The credits above are the analysis's internal record. The block below is the **user-facing** version for the eventual `MoonLive.md` "Prior art" section. Drop it in when MoonLive ships; matches the house style of the other modules' Prior-art sections (e.g. AudioModule.md, [LcdLedDriver.md](../moonmodules/light/moxygen/LcdLedDriver.md)). > MoonLive's native-codegen approach — compile a small C-like language straight to machine code and call it as a function, so a live-authored effect runs at near hand-written speed — was pioneered by **Yves Bazin (hpwit)** in **[ESPLiveScript](https://github.com/hpwit/ESPLiveScript)**: a from-scratch tokenizer, parser, and Xtensa code generator that drives a 12,288-LED panel at ~85 fps where interpreted languages (Lua, Gravity) managed 3–10. That result is what makes "go native, not interpreted" the right call, and ESPLiveScript is the reference MoonLive is built against — studied closely, credited, and written fresh against projectMM's architecture, never copied, per [*Industry standards, our own code*](../../CLAUDE.md#principles). MoonLive carries the idea forward where ESPLiveScript stops: a multi-ISA backend behind an IR seam (Xtensa, then RISC-V / ARM / desktop) and a binding that makes a script a first-class MoonModule. > diff --git a/docs/backlog/moonlight-effect-inventory.md b/docs/backlog/moonlight-effect-inventory.md index 01a5a765..8b4ed1bc 100644 --- a/docs/backlog/moonlight-effect-inventory.md +++ b/docs/backlog/moonlight-effect-inventory.md @@ -1,10 +1,10 @@ # MoonLight effect inventory (migration reference) -The full set of MoonLight effects to migrate, grouped by **origin library** (the doc-page split: `effects_<library>.md`), with audio/3D markers. Source: [MoonLight effects.md](https://github.com/MoonModules/MoonLight/blob/main/docs/moonlight/effects.md) + the `E_*.h` source files — studied for *behaviour*, reimplemented fresh per the migration plan's *Industry standards, our own code* rule. This reference feeds the [migration plan's](../history/plans/Plan-20260630%20-%20MoonLight%20migration%20(multi-stage).md) Stage-3 batches; it is *what to build*, not a copy of how. +The full set of MoonLight effects to migrate, grouped by **origin library** (a *section* within the shipped `effects.md` catalog page; a per-library page `effects_<library>.md` only when a section outgrows it — see the [folder-structure decision](folder-structure-proposal.md)), with audio/3D markers. Source: [MoonLight effects.md](https://github.com/MoonModules/MoonLight/blob/main/docs/moonlight/effects.md) + the `E_*.h` source files — studied for *behaviour*, reimplemented fresh per the migration plan's *Industry standards, our own code* rule. This reference feeds the [migration plan's](../history/plans/Plan-20260630%20-%20MoonLight%20migration%20(multi-stage).md) Stage-3 batches; it is *what to build*, not a copy of how. **Markers:** ♫ / ♪ audio-reactive · 🧊 native 3D. **Status:** ✅ already in projectMM · ⬜ to migrate. -## MoonLight library → `effects_moonlight.md` +## MoonLight library | Effect | Markers | Status | Notes | |---|---|---|---| @@ -26,7 +26,7 @@ The full set of MoonLight effects to migrate, grouped by **origin library** (the | Fixed Rectangle | | ⬜ | | | Star Sky | | ⬜ | | -## MoonModules library → `effects_moonmodules.md` +## MoonModules library | Effect | Markers | Status | Notes | |---|---|---|---| @@ -34,7 +34,7 @@ The full set of MoonLight effects to migrate, grouped by **origin library** (the | PaintBrush | ♫ 🧊 | ⬜ | audio + 3D | | Game Of Life | 🧊 | ✅⚠️ | **re-port** — current version flagged not faithful (migration Stage 1 proof) | -## WLED library → `effects_wled.md` +## WLED library | Effect | Markers | Status | Notes | |---|---|---|---| @@ -50,14 +50,14 @@ The full set of MoonLight effects to migrate, grouped by **origin library** (the | PopCorn | ♪ | ⬜ | physics + audio | | Waverly | ♪ | ⬜ | audio | -## Moving-head library → `effects_movingheads.md` (Stage 5, DMX fixtures) +## Moving-head library (Stage 5, DMX fixtures) | Effect | Markers | Status | |---|---|---| | Troy1 Color / Troy1 Move / Troy2 Color / Troy2 Move | ♫ | ⬜ | | FreqColors · Wowi Move · Ambient Move | ♫ | ⬜ | -## projectMM-native (no external origin) → `effects_projectmm.md` +## projectMM-native (no external origin) Already in projectMM, our own (not from a MoonLight library — kept here so the inventory is complete): AudioSpectrumEffect ♫, AudioVolumeEffect ♫, FireEffect, GlowParticlesEffect, LavaLampEffect, MetaballsEffect, NetworkReceiveEffect, PlasmaEffect, PlasmaPaletteEffect, RingsEffect, SpiralEffect, CheckerboardEffect. diff --git a/docs/backlog/virtual-layer-downscale-study.md b/docs/backlog/virtual-layer-downscale-study.md new file mode 100644 index 00000000..21f93d90 --- /dev/null +++ b/docs/backlog/virtual-layer-downscale-study.md @@ -0,0 +1,116 @@ +# Virtual-layer render + pooling down-map — design study + +> A forward-looking design study (backlog, present-tense-exempt). It captures a feature the product +> owner requested as a "hue mixer modifier" and the architecture verification that reframed it into a +> **Layer** feature. Not yet planned for implementation — a future increment picks it up. Supersedes +> the earlier `DownscaleModifier-spec.md` draft (deleted), which framed it as a modifier. + +## Goal + +Run an effect authored for **many** pixels — a virtual 32×32 grid — and **summarize** it onto a +**small** number of physical lights: Philips Hue bulbs, or a handful of PAR/DMX fixtures. Each +physical light shows the **average colour of its region of the virtual frame, ignoring black +pixels**, so a fire / plasma / GEQ effect reads as a coherent ambient wash across N bulbs instead of N +arbitrary single-pixel samples. + +Concretely: a `Fire` effect on a 32×32 grid, driven onto 10 Hue bulbs, makes each bulb glow the mean +of its ~102-pixel slice of the fire — the flames' *summary*, not one stray pixel. + +The mirror case (**physical ≥ virtual**, e.g. a 128×128 wall from a 32×32 effect) is **already +solved** — the existing `MappingLUT` fans a small logical grid out across a larger physical set (1:N +upscale). The genuinely new capability is the **downscale** direction (physical < virtual): render +big, summarize small. + +## Why this is a Layer feature, not a modifier + +The request began as "make a modifier." Two verification steps ruled that out: + +1. **Not `modifyLive`.** The per-frame modifier hook is a *backward 1:1 coordinate gather*: the Layer + walks destination pixels and asks the modifier for **one** source coordinate, then copies **one** + pixel (`Layer::applyLivePass`, Layer.h:~218). Pooling *combines* many source pixels into one output + value (sum + count, skip black, divide) — a computation a coordinate remap structurally can't + express (it returns a coordinate, not a colour). + +2. **Not a modifier at all — the real blocker is render size.** A Layer's logical (render) box + **starts at the physical light count** and modifiers only ever *shrink* it (`Layer::rebuildLUT`, + Layer.h:343: `Coord3D box{physicalWidth_, physicalHeight_, physicalDepth_}` → each static modifier + reshapes it → `width_ = logical.x`). So a Layer renders at *most* at its physical size. A 32×32 + effect onto 10 physical lights **cannot happen today** — the effect would render at 10, leaving + nothing to pool *from*. A driver-side pool hits the same wall: the driver reads the layer's logical + buffer (zero-copy) or the LUT-mapped physical `outputBuffer_` (Drivers.h:~264) — both are already + physical-sized. + +So the enabling feature is a **Layer virtual/render size that can exceed the physical light set** — +exactly MoonLight's `VirtualLayer` (a per-layer virtual buffer, render at a virtual size, map to +physical), which the Layer doc already cites as prior art (Layer.h:33). Once a Layer can render at a +virtual grid independent of N physical lights, "summarize down" is just **the virtual→physical +down-map rule**. It lives in the Layer / mapping, not a modifier. + +## Proposed shape (for a future implementation plan) + +Two parts; the first is the enabler, the second is the actual summarize: + +### 1. Layer virtual size (the enabler) + +A Layer control (or layout property) setting a **render box independent of, and allowed to exceed,** +the physical light count. Effects render into that virtual box; the mapping reduces it to the physical +lights. Care points a future plan must resolve: + +- **`rebuildLUT` assumes logical ≤ physical.** It backward-maps each *physical* light to *one* logical + cell (a fan-out / scatter). A virtual grid *larger* than physical needs the inverse: many logical + cells → one physical light. `MappingLUT` today has 1:1, 1:0 (unmapped), and 1:N (fan-out) mapping + types but **no N:1-averaged type** — that's the new mapping the feature introduces. +- **Memory:** the virtual buffer is `virtualW × virtualH × virtualD × channels`, which can dwarf the + physical light count (32×32×3 = 3 KB is fine; larger virtual grids need the same PSRAM-first + allocation + degradation-status discipline the Layer already uses). + +### 2. The pooling down-map (the summarize) + +`physical[i] = mean(non-black pixels of virtual region i)`. Two open sub-designs for the future plan: + +- **Where it runs:** a per-frame buffer pass (an N-slot accumulator — `rSum/gSum/bSum/count` per + physical light, sized on the cold path, zeroed + filled per frame) attached to the **Layer**; OR a + new `MappingLUT` N:1-average mapping type that `blendMap` applies. The accumulator approach is + simpler and self-contained; the LUT-type approach is more uniform with the existing mapping model. +- **Region assignment (`regionOf`) — derived from the physical shape, not a user control.** The split + follows the *dimensionality of the physical light set* (read from `Layouts` / the physical dims the + Layer already knows): + - **1D physical** (N bulbs in a line): N contiguous bands along the virtual grid's dominant axis. + - **2D physical** (a panel/cluster, Pw×Ph): a Pw×Ph tiling; each physical light = the mean of its + tile. + - **3D physical** (a cube): 3D blocks. + + One integer formula per axis (`region_a = coord_a × physDim_a / virtualDim_a`), no user control, no + 1D-vs-2D mode. Add/remove lights or change the physical layout and it re-slices automatically (the + *No reboot to apply* / robustness guarantees). The shape is *data*, so the general algorithm reading + physical dims is the textbook construct — cleaner than shipping "1D bands first." + +- **Skip black:** an unlit virtual pixel doesn't dilute the average (`count[slot]` only increments for + non-black pixels; an all-black region → the physical light is off). Integer math only; no heap in the + per-frame pass. + +### Driver-agnostic + +The output is N physical light values — whatever driver reads them (Hue over HTTP, a NetworkSend +driver over ArtNet/E1.31 to N DMX PAR channels, an RMT strand of N pixels) shows the summary. The +goal's "or a number of PAR lights for that matter" falls out for free. + +## Tests (when built) + +The pooling math and the `regionOf` split are **pure and host-testable**, wherever they end up living: + +- **Unit:** feed a synthetic virtual buffer (gradient, half-black frame) + N; assert each physical + value equals the mean of its non-black region; all-black region → off; correctness at N=1, N=10, + N=(virtual pixel count) = identity, N > pixel count (empty slots). +- **Unit:** `regionOf` — contiguous, complete (every virtual pixel in exactly one region), no + gaps/overlap, at awkward N (7, 13) and in 1D/2D/3D. +- **Scenario:** a Fire / Noise effect on a virtual grid → summarized onto a fake N-light driver → + non-zero, stable output; robustness (change virtual size + N live, 0×0 grid) never crashes. + +## Why parked + +This is a Layer-level feature (a new render-size concept + a new N:1-average mapping), materially +bigger than the "quick modifier" it was first requested as. It warrants its own spec + `/plan` + +increment rather than being wedged into the modifier framework (where it fought three invariants: the +1:1 gather, free composition, and logical-space operation). Captured here so the design and the +architecture findings aren't lost; a future increment picks it up. diff --git a/docs/building.md b/docs/building.md index 9427f494..94809e4f 100644 --- a/docs/building.md +++ b/docs/building.md @@ -140,7 +140,7 @@ The ESP32 tab in MoonDeck wraps the same steps as cards (Setup → Firmware → **v6.0 vs v6.1, and where the real change was.** The earthquake was **v5.x → v6.0**, not v6.0 → v6.1: -- **v6.0** (vs v5.x): the legacy peripheral drivers were **removed entirely** (ADC, DAC, I2S, Timer, PCNT, MCPWM, **RMT**, temp sensor), which is why the LED drivers use the modern RMT v2 / parlio / `esp_lcd` APIs (rationale at [RmtLedDriver.md](moonmodules/light/drivers/RmtLedDriver.md)); **picolibc** replaced newlib as the default C library; **warnings-as-errors** became the default (matches our own `-Werror`); the `CONFIG_ESP_WIFI_ENABLED` switch was dropped (forced on for WiFi SoCs, hence the `EXCLUDE_COMPONENTS` path documented under [Firmware variants](#firmware-variants)); plus the new install manager (EIM), a built-in MCP server, CMake Build System v2 (preview), `wifi_provisioning` → `network_provisioning`, PSA Crypto, and new chips (C5/C61 full, H21/H4 preview). +- **v6.0** (vs v5.x): the legacy peripheral drivers were **removed entirely** (ADC, DAC, I2S, Timer, PCNT, MCPWM, **RMT**, temp sensor), which is why the LED drivers use the modern RMT v2 / parlio / `esp_lcd` APIs (rationale at [RmtLedDriver.md](moonmodules/light/moxygen/RmtLedDriver.md)); **picolibc** replaced newlib as the default C library; **warnings-as-errors** became the default (matches our own `-Werror`); the `CONFIG_ESP_WIFI_ENABLED` switch was dropped (forced on for WiFi SoCs, hence the `EXCLUDE_COMPONENTS` path documented under [Firmware variants](#firmware-variants)); plus the new install manager (EIM), a built-in MCP server, CMake Build System v2 (preview), `wifi_provisioning` → `network_provisioning`, PSA Crypto, and new chips (C5/C61 full, H21/H4 preview). - **v6.1** (vs v6.0): an ordinary minor — bugfixes, more chip maturity, incremental features on the v6.0 baseline. No second mass-removal. Because it is still beta, its feature set isn't frozen until RC1. **Support / EOL policy.** Each *stable* ESP-IDF release is supported for **30 months** from its GA date, split into a Service period (frequent bugfix releases, occasional regulatory features) and a Maintenance period (security and high-severity fixes only). Pre-release and dev snapshots get none of this. So pinning to a GA tag (v6.0 today, or v6.1 after 2026-07-31) is what buys the support window; riding `v6.1-dev` does not. diff --git a/docs/coding-standards.md b/docs/coding-standards.md index cb50dde6..a86df7ac 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -116,21 +116,34 @@ Where each kind of fact lives. The guiding rule: **document a thing once, in the ### The two surfaces -1. **Summary page (hand-written, end-user).** One `.md` per module *group*, a 4-column table — **name + description · gif/image · controls · links** (per module: tests · its technical page · attribution · anchors to any extra prose). One row per module, authored as `###` prose blocks that a build-time hook ([`moondeck/docs/mkdocs_hooks.py`](../moondeck/docs/mkdocs_hooks.py)) renders as the table. Catalog controls live here because a catalog module's user surface is its runtime `controls_.add(...)` calls, which no static tool sees. Each group is nested in its own subfolder, consistent with the catalog: - - `light/{effects,modifiers,layouts,drivers}/<name>.md` — the four light-catalog pages (may later split, e.g. `effects_wled.md` / `effects_moonmodules.md`). - - `core/ui/`, `core/supporting/`, `light/supporting/` — the core-UI, core-supporting, and light-supporting summary pages. +1. **Summary page (hand-written, end-user).** One `.md` per module *group*, a 4-column table — **name + description · gif/image · controls · links**. One row per module, authored as `###` prose blocks that a build-time hook ([`moondeck/docs/mkdocs_hooks.py`](../moondeck/docs/mkdocs_hooks.py)) renders as the table. Catalog controls live here because a catalog module's user surface is its runtime `controls_.add(...)` calls, which no static tool sees. Each group is one flat page under its domain (a group's `type` rides in the *page name*, not a subfolder — the [folder-structure decision](backlog/folder-structure-proposal.md)): + - `light/{effects,modifiers,layouts,drivers,supporting}.md` — the light-catalog + light-supporting pages (a type may later split by library into `effects_wled.md` / `effects_moonmodules.md`, still flat). + - `core/{services,supporting,ui}.md` — the core-services (user-facing modules), core-supporting, and web-UI summary pages. - Cross-file design rationale that no single `.h` owns (module interactions, buffer-lifecycle coupling) is a prose section beneath a summary page's table — the only home for it, so a module needs no page of its own. + **The Links column** is assembled by the hook in a fixed order — **🧪 Tests · 📄 Technical · attribution · ⌄ details** — each with a Material icon (`:material-…:`, rendered as inline SVG by `pymdownx.emoji`, the same mechanism as the tag emoji in the Name column) so a link's *type* is scannable. A card carries these lines: a `[Tests](../../../tests/unit-tests.md#<anchor>)` line (omitted when the module has no unit test — a missing Tests link truthfully means "untested"), a **`Detail: [technical](../moxygen/<Stem>.md)`** line pointing at the generated technical page, and an `Origin:` attribution line. `check_specs` matches each block to its `.h` via that `moxygen/<Stem>.md` link, so keep the link's target on it. -2. **Technical page (generated).** `docs/moonmodules/{core,light}/moxygen/<Module>.md`, produced from the `.h` by [`moondeck/docs/gen_api.py`](../moondeck/docs/gen_api.py): **Doxygen** (the de-facto-standard C++ parser) emits XML, **moxygen** renders Markdown through a custom Handlebars template ([`moondeck/docs/moxygen-templates/`](../moondeck/docs/moxygen-templates/)). Layout is controlled only through Doxygen config + that template — moxygen's own levers, the output is used verbatim, never post-processed. Each page carries the module's **class, variables, and members** from their `///` comments and links to its `.h`; a summary page's per-module link points here. + Cross-file design rationale that no single `.h` owns (module interactions, buffer-lifecycle coupling) is a prose section beneath a summary page's table — a `## <Name> — details` section the hook links from the row as `⌄ details`. That's the only home for it, so a module needs no page of its own. + +2. **Technical page (generated).** `docs/moonmodules/{core,light}/moxygen/<Module>.md`, produced from the `.h` by [`moondeck/docs/gen_api.py`](../moondeck/docs/gen_api.py): **Doxygen** (the de-facto-standard C++ parser) emits XML, **moxygen** renders Markdown through a custom Handlebars template ([`moondeck/docs/moxygen-templates/`](../moondeck/docs/moxygen-templates/)). Each page carries the module's **description, variables, and members** from their `///` comments and links to its `.h`; the summary page's per-module `Detail: [technical]` link points here. Two shaping levers beyond raw moxygen, both to keep the page lean: + - **Template** (`class.md`): the base class is a one-line `> **Inherits:** [Base]` link — the full inherited-member list is *not* re-dumped on every subclass (it lives once on the base's own page; re-listing `MoonModule`/`EffectBase`'s large surface everywhere is bloat — *No duplication*). + - **Post-process** in `gen_api.py`: a `@card <file.png>` directive in a class `///` comment renders to an `<img>` of the module's UI-card screenshot. Doxygen with `GENERATE_HTML=NO` drops `\image`/`@htmlonly`/raw `<img>` from the XML but preserves plain text, so `@card` survives and is rendered here (a missing asset drops the directive — no broken link). A second post-process wraps each member signature's declared **name** in `<code class="mm-sig">…<span class="mm-sig-name">name</span>…</code>` so the theme ([`extra.css`](assets/extra.css)) can highlight the name (accent, bold) while the type and arguments stay muted — moxygen emits a flat `<code>` string with no internal markup, so 'colour only the name' can't be done in CSS alone. Site-wide, `extra.css` also colours `h1`/`h2`/`h3` with the theme's primary/accent (the slate theme otherwise renders every heading the same near-white, flattening the hierarchy). The pages are **gitignored, regenerated on build** (flipping to committed-and-drift-gated is a one-line `.gitignore` change plus a gate like `check_firmwares.py`, if PR-review of the generated output ever earns it). Doxygen (a brew/apt binary) and moxygen (via npx) are the one justified non-uv dependency, like ESP-IDF's Python (see CLAUDE.md); absent locally the pages skip and the rest of the site builds, present in CI they render. ### `///` comments are the single home for technical detail -A module's per-entity detail — the module description, each variable, each member — is a `///` / `///<` comment in the `.h`. It generates into the technical page and shows on IDE hover. Two gotchas: a bare `<tag>` in a comment renders as a live HTML element and swallows the rest of the page (wrap any `<…>` in backticks), and Doxygen's `JAVADOC_AUTOBRIEF` ends the brief at the first `.` (write "such as", not "e.g."). Where a comment would hand-copy a wire format / enum / constant, embed the real source with a `--8<--` snippet instead (`// --8<-- [start:name]`). +A module's per-entity detail — the module description, each variable, each member — is a `///` / `///<` comment in the `.h`. It generates into the technical page and shows on IDE hover. Rules that decide what actually reaches the generated page: + +- **`///`, not `//`.** Only `///` (Doxygen) comments generate; a plain `//` comment is invisible on the technical page (it's an implementation note for the source reader only). Class-level rationale you want documented goes in `///`. +- **Distribute detail to the member it describes; keep the class comment compact.** The class `///` states what the module *is* and its one defining contract — not a wall of per-control / per-method prose. Every public attribute (the config controls) and every public method gets its OWN `///`, leading with a tight one-sentence brief (the generated page shows that first sentence as the member's summary, so the reader scans a list of named declarations each with its purpose). Detail that belongs to one control (its range, what changing it does) lives on that control; detail that belongs to one method (its lifecycle role, its guarantees) lives on that method. This spreads the same information across the entities it documents instead of piling it into the class blurb, so nothing is repeated and each member is self-describing on hover and on the page. Don't restate what a superclass already documents (a `DriverBase` hook's contract lives on `DriverBase`, not re-explained on every driver). Compact, dense prose — relocate detail, don't delete it. +- **The class comment must sit directly above the class**, inside `namespace mm {` — a `///` block separated from `class X` by the `#include`s or the namespace open is detached and Doxygen drops it (its whole description then goes missing from the page). Put the includes first, then `namespace mm {`, then the `///` block, then the class. +- **Relative `.md`/doc links are stripped** by Doxygen's XML-only mode (only `http(s)://` links survive). Don't put `[text](../some.md)` in a `///` comment — link with a full URL, or name the target in prose (e.g. "see the LED signal-integrity use-case guide"). +- A bare `<tag>` renders as a live HTML element and swallows the rest of the page (wrap any `<…>` in backticks), and `JAVADOC_AUTOBRIEF` ends the brief at the first `.` (write "such as", not "e.g."). +- Where a comment would hand-copy a wire format / enum / constant, embed the real source with a `--8<--` snippet (`// --8<-- [start:name]`). Add `@card <file.png>` to show the module's UI card. + +A module's story therefore lives in exactly two places: its `.h` (technical, generated) and its group summary row (end-user), which links to the technical page. Prior-art credit points at *other* projects learned from (FastLED, WLED, MoonLight, datasheets); superseded internal prototypes are not linked. -A module's story therefore lives in exactly two places: its `.h` (technical, generated) and its group summary row (end-user). Prior-art credit points at *other* projects learned from (FastLED, WLED, MoonLight, datasheets); superseded internal prototypes are not linked. +**No per-module detail `.md`.** A module's technical detail lives in its `.h` `///` (→ the generated page), never a separate hand-written detail page. When a common rationale repeats across sibling modules (e.g. the shared parallel-LED-driver body), it lives once on the shared base class's `///` and the siblings reference it — the same *No duplication* rule, applied to the generated pages. ### Test inventories: their own generator, not moxygen diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index d6d8499d..7bc62e7f 100644 --- a/docs/gettingstarted.md +++ b/docs/gettingstarted.md @@ -166,7 +166,7 @@ the device; it never slows the lights down, and it gracefully eases off (fewer updates, then fewer points) on a slow connection rather than stalling. > More on how the preview streams from the device: -> [PreviewDriver](moonmodules/light/drivers/PreviewDriver.md). +> [PreviewDriver](moonmodules/light/moxygen/PreviewDriver.md). ## The system modules @@ -183,8 +183,8 @@ network — lives here as its own module, and we're adding more all the time. ![The System module](assets/gettingstarted/02-05-UI-System.png) -> [SystemModule](moonmodules/core/ui/ui.md) · -> [AudioModule](moonmodules/core/ui/ui.md) +> [SystemModule](moonmodules/core/services.md#system) · +> [AudioModule](moonmodules/core/services.md#audio) **Firmware** — which build you're running, and where you update it. The **Install** button here does an over-the-air update straight from the device — no @@ -192,7 +192,7 @@ USB cable needed once it's on your network. ![The Firmware module](assets/gettingstarted/02-06-UI-Firmware.png) -> [FirmwareUpdateModule](moonmodules/core/ui/ui.md) +> [FirmwareUpdateModule](moonmodules/core/services.md#firmware-update) **Network** — your connection: WiFi or Ethernet, signal strength, and the address others reach it at. The **Devices** section underneath finds other @@ -201,8 +201,8 @@ other. ![The Network module](assets/gettingstarted/02-07-UI-Network.png) -> [NetworkModule](moonmodules/core/ui/ui.md) · -> [DevicesModule](moonmodules/core/ui/ui.md) +> [NetworkModule](moonmodules/core/services.md#network) · +> [DevicesModule](moonmodules/core/services.md#devices) > **Lights are just one use.** Everything above — the modules, the live controls, the > 3D view, the web UI, the networking — is a general-purpose engine that knows nothing @@ -224,7 +224,7 @@ on **serpentine** if your strip zig-zags back and forth. ![The Layouts module](assets/gettingstarted/02-08-UI-Layouts.png) -> [Layouts](moonmodules/light/supporting/supporting.md) +> [Layouts](moonmodules/light/supporting.md) **Layers** — what plays on the lights. Add an **effect** (a moving pattern), stack several to blend them, and reshape them with **modifiers** (mirror, rotate, and @@ -233,7 +233,7 @@ tweak live. ![The Layers module](assets/gettingstarted/02-09-UI-Layers.png) -> [Layers](moonmodules/light/supporting/supporting.md) · [Layer](moonmodules/light/supporting/supporting.md) +> [Layers](moonmodules/light/supporting.md) · [Layer](moonmodules/light/supporting.md) **Drivers** — where the colours go. Set overall **brightness** and colour order, then add an output: real LED strips on a pin, or send the frame over the network @@ -241,8 +241,8 @@ then add an output: real LED strips on a pin, or send the frame over the network ![The Drivers module](assets/gettingstarted/02-10-UI-Drivers.png) -> [Drivers](moonmodules/light/supporting/supporting.md) · -> [NetworkSendDriver](moonmodules/light/drivers/NetworkSendDriver.md) +> [Drivers](moonmodules/light/supporting.md) · +> [NetworkSendDriver](moonmodules/light/moxygen/NetworkSendDriver.md) That's the whole picture: **layout → layers → drivers**, previewed in 3D, all tuned live in your browser. Pick an effect, drag a slider, watch the lights — then diff --git a/docs/history/decisions.md b/docs/history/decisions.md index 359d84af..b5d2710e 100644 --- a/docs/history/decisions.md +++ b/docs/history/decisions.md @@ -601,7 +601,7 @@ A classic ESP32 driving a WS2812 strip on RMT showed random wrong colours on LED 3. **Sweep `txPowerSetting` 20 → 1 dBm** while watching the strip — the flicker was *constant*. A ~50× drop in radiated RF changed nothing, so it is **not** WiFi coupling into the data line (the standing hypothesis, disproven by the experiment). 4. **Check the pulse timing** — 350/700/1250 ns, spec-exact, and the loopback confirms the wire carries them. Not a timing-margin bug like the LCD T0H case. -With every firmware cause eliminated *by test*, the remaining cause is the physical data path — and "constant regardless of TX power" specifically fingers electrical signal integrity over radio. On a 3.3 V part driving WS2812 directly, the dominant cause is the missing 3.3 → 5 V level shift (the LED's logic-high threshold sits above what the GPIO drives, so marginal bits flip under any noise). Fix is hardware: level shifter, series resistor, shorter/grounded wire — documented in [RmtLedDriver.md § Troubleshooting](../moonmodules/light/drivers/RmtLedDriver.md). +With every firmware cause eliminated *by test*, the remaining cause is the physical data path — and "constant regardless of TX power" specifically fingers electrical signal integrity over radio. On a 3.3 V part driving WS2812 directly, the dominant cause is the missing 3.3 → 5 V level shift (the LED's logic-high threshold sits above what the GPIO drives, so marginal bits flip under any noise). Fix is hardware: level shifter, series resistor, shorter/grounded wire — documented in [LED signal integrity](../usecases/led-signal-integrity.md). **The transferable lesson** is the order: when hardware output looks wrong, prove the firmware is *not* the cause with a measurement at each layer (buffer → encode/transmit loopback → environment sweep) before either editing code or buying parts. Two strong hypotheses here (a buffering regression, WiFi interference) were both wrong, and only the measurements said so — guessing would have burned a level shifter's worth of time on the wrong layer, or "fixed" code that was never broken. The whole-frame loopback self-test exists precisely so step 2 is a one-click answer instead of a scope session. @@ -803,6 +803,8 @@ The docs overhaul first tried to *shrink* the per-module `.md` files, then inver - **A generator that's *present but failing* must fail loud, not return empty.** `gen_api.generate()` first returned `{}` on any error — so a transient `npx` registry hiccup would ship a docs site with **zero** API pages and no red X. Split the two cases: toolchain *absent* → graceful `{}` (a contributor without Doxygen still builds the rest); toolchain *present but failing* (or under-producing vs a floor) → raise. Silent degradation in a generated artifact is worse than a hard failure, because nobody notices until a reader hits a 404. - **Enrichment by parallel agents needs an adversarial read, not just a build.** Worker agents enriched 19 headers; all compiled and generated. But the pre-merge reviewer + CodeRabbit still found real content bugs a build can't catch: a future-tense roadmap sentence (violates present-tense), a core-affinity claim describing plumbing that doesn't exist (inherited verbatim from the old `.md` — the inaccuracy pre-dated the enrichment), and an intentional default-value change (`TextEffect hue 0→128`) riding in unremarked and untested. "Compiles + generates" is table stakes; prose accuracy needs a human/reviewer pass, and an *intentional* behaviour change (even a good one) needs a test pinning it. - **A migration is only net-subtractive once the old thing is deleted — stage the deletion, but don't call it done early.** Moving the old `.md` to `archive/` (instead of deleting) kept them for cross-check but left the branch net-positive and shipped a temporary migration banner on every generated page. That's fine as an explicit *stage*, but the banner and archive are debt with a name and a removal plan, not a resting state. Mark such scaffolding "temporary / removed at Stage N" at every site so the cleanup is mechanical. +- **A doc path duplicated in *code* drifts silently when the doc moves — gate it, don't trust it.** Each module's `main.cpp` `registerType("Name", "docPath")` builds the in-UI help link, but nothing validated the docPath resolved. A docs rename (deleting per-driver pages, flattening `light/effects/effects.md` → `light/effects.md`) left ~10 of them 404-ing in the running UI — invisible to the desktop build, ctest, and the docs build alike, caught only by a reviewer eyeballing the diff. The fix is a `check_specs.py` gate that parses every docPath and asserts it resolves to a real page **and** `#anchor`. Rule: a path (or any fact) that lives in *code* but points at *docs* is a cross-boundary duplication with no compiler guarding it — the moment you can write a check that resolves it against ground truth, that check is cheaper than the drift it prevents. +- **Verify a generated anchor against the *built HTML*, never a re-derived slug.** Auditing those docPath anchors, an agent computed each `#anchor` with the stock python-`markdown` `toc` slugify and declared 52 links "broken" (`#fire` should be `#fire-2d`, etc.). All 52 were actually *correct* — the catalog-table build hook emits its own `<a id="fire">` anchors from the card name, overriding the heading slug the standalone slugify guessed. Applying the agent's "fixes" would have broken 52 working links. Rule: when a build hook post-processes a page, the only authority for what anchors exist is the *rendered output* (`grep id=` the built `.html`), not a reimplementation of the slug algorithm — the hook is free to disagree with the default, and here it did. ## ESP32-S31 RGMII Ethernet bring-up: four bugs between "code compiles" and "link up" diff --git a/docs/history/plans/Plan-20260627 - MoonLive Stage 1 controls (shipped).md b/docs/history/plans/Plan-20260627 - MoonLive Stage 1 controls (shipped).md index 777ff434..ce04b314 100644 --- a/docs/history/plans/Plan-20260627 - MoonLive Stage 1 controls (shipped).md +++ b/docs/history/plans/Plan-20260627 - MoonLive Stage 1 controls (shipped).md @@ -113,7 +113,7 @@ struct DeclaredControl { const char* name; uint8_t type; int32_t min, max, def; scratch shift + `l8ui`. **`…_riscv` pair** — kArg4→a4 + scratch shift + `lbu` + call() saves a4. - **`src/light/moonlive/MoonLiveEffect.h`** — dynamic `onBuildControls` + arena-bound `addUint8` + arena alloc/seed in `onBuildState`. -- **Docs**: `docs/moonmodules/light/moonlive/MoonLiveEffect.md` (the `@control` contract), +- **Docs**: `docs/moonmodules/light/MoonLiveEffect.md` (the `@control` contract), `docs/backlog/livescripts-analysis-top-down.md` (mark Stage 1 done in the §9 ladder), `docs/history/decisions.md` (the live-control-via-arena lesson). diff --git a/docs/history/plans/Plan-20260630 - Doc consolidation - per-type compact pages (effects-modifiers-layouts).md b/docs/history/plans/Plan-20260630 - Doc consolidation - per-type compact pages (effects-modifiers-layouts).md deleted file mode 100644 index e0a10e48..00000000 --- a/docs/history/plans/Plan-20260630 - Doc consolidation - per-type compact pages (effects-modifiers-layouts).md +++ /dev/null @@ -1,57 +0,0 @@ -# Plan — Doc consolidation: per-type compact pages (effects / modifiers / layouts) - -This is **Stage 2** of the [MoonLight migration](./Plan-20260630%20-%20MoonLight%20migration%20(multi-stage).md), narrowed by two product-owner decisions made at planning time: - -1. **Docs only.** `src/` stays one-`.h`-per-module (the recorded [folder-structure decision](../../backlog/folder-structure-proposal.md): a blended-origin effect in a per-library *folder* forces a wrong, costly multi-file move). This change touches only `docs/` + `check_specs.py`. -2. **One page per *type*, not per *library* — for now.** Our effect origins are lopsided (10 MoonLight, 1 WLED, 2 FastLED, 4 projectMM-native); a strict per-library page split (the proposal's first draft) yields 1–2-effect sparse pages. Instead: one `effects.md` with **library *sections* inside** (MoonLight / WLED / FastLED / projectMM-native), exactly like the [MoonLight effects page](https://moonmodules.org/MoonLight/moonlight/effects/). The per-library *file* split stays in the backlog as **future growth** — when the WLED/FastLED sets grow, a section lifts into its own file with no row rework. - -Inspiration for the compact per-module presentation: the MoonLight effects page (one table row per effect: name + tags, preview gif, one-line description, controls screenshot). - -## Scope - -- **Effects:** ~17 per-module `.md` → one `docs/moonmodules/light/effects.md`, compact rows, library sections. -- **Modifiers:** ~5 per-module `.md` → one `docs/moonmodules/light/modifiers.md`. -- **Layouts:** ~4 per-module `.md` → one `docs/moonmodules/light/layouts.md`. -- **Drivers:** **unchanged** — one `.md` per driver (PO decision: drivers are larger and structurally distinct — RMT/LCD/Parlio/Network/Hue avg ~215 lines with platform-specific wire contracts a compact row can't hold). -- **Core modules:** unchanged (stable count, no explosion). - -## Row format (per module) - -`| Name (+ tags emoji) | preview | one-line description | controls |` - -- **Drops** the per-module `Tests` / `Prior art` / `Source` / `Design notes` sections: source is derivable from the name, tests are auto-discovered by `generate_test_docs.py`, prior-art/origin rides in the tags emoji + a short note in the description cell. Wire contracts (the one thing the row CAN'T hold) — none of effects/modifiers/layouts have integrator-facing wire contracts except `NetworkReceiveEffect`, which keeps a short protocol note in its description cell (or a footnote under its section). -- ~3–4 lines per module, so `effects.md` ≈ 17 rows ≈ 90 lines (vs ~1100 lines across 17 files today). - -## `check_specs.py` rewrite (the contract change) - -Today: `check_specs.py` rglobs each module `.h` → requires a matching per-module `.md` whose body mentions every control name. New contract: - -- A module registered with `light/effects.md` (the page, not a per-module file) passes if **every one of its control names appears somewhere on that page** (in its row). Same anti-drift guarantee, page-scoped instead of file-scoped. -- The registered `.md` arg in `main.cpp` changes from `light/effects/Rainbow.md` → `light/effects.md` (+ optional `#rainbow` anchor) for every effect; same for modifiers/layouts. Drivers keep their per-driver `.md`. -- Map each module → its page by type suffix (`*Effect` → `effects.md`, `*Modifier` → `modifiers.md`, `*Layout`/`Layouts` → `layouts.md`, `*Driver`/`Drivers` → its own per-driver page), mirroring `asset_dir_for()`. - -## Files - -- **New:** `docs/moonmodules/light/effects.md`, `modifiers.md`, `layouts.md` (compact-row pages with library sections). -- **Delete:** the ~17 `docs/moonmodules/light/effects/*.md`, ~5 `modifiers/*.md`, ~4 `layouts/*.md` (folded into the pages). -- **Edit:** `src/main.cpp` (registered `.md` path per effect/modifier/layout → the page), `moondeck/check/check_specs.py` (page-scoped control-name check + the type→page map), `moondeck/docs/generate_test_docs.py` if it links per-module `.md` anchors, and any doc that links a deleted per-module page (re-point to `effects.md#name`). -- **Unchanged:** `src/light/effects/*.h` etc. (code), `docs/moonmodules/light/drivers/*.md` (per-driver), `docs/moonmodules/core/*`. - -## Origin sections (from each module's `tags()` today) - -- **Effects** — MoonLight 💫: DistortionWaves, LavaLamp, Lines, Metaballs, Particles, Plasma, Rainbow, Rings, Ripples, Spiral · WLED 🌊: Wave · FastLED ⚡️: Fire, Noise · projectMM-native: AudioSpectrum 📊, AudioVolume 🔊, Sine 🌀, NetworkReceive 📡🌙. -- **Modifiers** — MoonLight 💫: Checkerboard, Multiply · projectMM-native: RandomMap, Region, Rotate. -- **Layouts** — projectMM-native: Grid, Sphere, Wheel, Layouts. - -## Verification - -- `check_specs.py` green under the new page-scoped contract (every control name present on its page). -- Build/tests/scenarios unaffected (docs-only + a script change; no `src/` compile impact). -- No dangling links: every former per-module `.md` link re-points to `page.md#anchor`; `generate_test_docs.py` output still resolves. -- The three pages render as compact tables with library sections; drivers + core docs untouched. - -## Out of scope (future growth, kept in backlog) - -- **Per-library *file* split** (`effects_moonlight.md`, …) — revisit when a library's effect count earns its own page; the within-page sections make it a lift-not-rewrite. -- **Driver doc consolidation** — drivers stay per-file. -- **gif previews** — adding MoonLight preview gifs into the rows is the migration's separate asset work. diff --git a/docs/history/plans/Plan-20260630 - Stage 3 E_MoonModules batch (GameOfLife + GEQ3D + PaintBrush) (shipped).md b/docs/history/plans/Plan-20260630 - Stage 3 E_MoonModules batch (GameOfLife + GEQ3D + PaintBrush) (shipped).md index 24f187ca..44f906ec 100644 --- a/docs/history/plans/Plan-20260630 - Stage 3 E_MoonModules batch (GameOfLife + GEQ3D + PaintBrush) (shipped).md +++ b/docs/history/plans/Plan-20260630 - Stage 3 E_MoonModules batch (GameOfLife + GEQ3D + PaintBrush) (shipped).md @@ -31,7 +31,7 @@ The first effect-migration batch of [MoonLight migration Stage 3](./Plan-2026063 ## Files - **New:** `src/light/effects/GameOfLifeEffect.h`, `GEQ3DEffect.h`, `PaintBrushEffect.h`; `src/core/crc.h` (crc16 for GoL stasis). -- **Edit:** `src/main.cpp` (register the 3, each → `light/effects/effects.md#<anchor>`), `test/scenario_runner.cpp` (register for scenarios), `docs/moonmodules/light/effects/effects.md` (3 rows + `## Source` links + anchors), `test/CMakeLists.txt` (the new unit tests). +- **Edit:** `src/main.cpp` (register the 3, each → `light/effects.md#<anchor>`), `test/scenario_runner.cpp` (register for scenarios), `docs/moonmodules/light/effects.md` (3 rows + `## Source` links + anchors), `test/CMakeLists.txt` (the new unit tests). - **Tests (new):** `unit_GameOfLifeEffect.cpp` (Conway still-life stays, blinker oscillates, B/S parser, neighbour count 2D+3D, no-crash on 0×0×0), `unit_crc.cpp` (crc16 known vectors), and GEQ3D/PaintBrush covered by the shared render test (`unit_effects_render` STATELESS / non-zero with a fed AudioFrame) + a scenario. Audio effects render dark on silence (safe) — assert structure on a synthetic frame where testable. - **Scenarios:** add the 3 to the perf/all-effects sweep; GoL gets its own scenario (gen progression renders + doesn't crash at any grid size). diff --git a/docs/history/plans/Plan-20260702 - Docs Phase 1+2 (nav fold + tests in build).md b/docs/history/plans/Plan-20260702 - Docs Phase 1+2 (nav fold + tests in build).md deleted file mode 100644 index 57ac27f9..00000000 --- a/docs/history/plans/Plan-20260702 - Docs Phase 1+2 (nav fold + tests in build).md +++ /dev/null @@ -1,44 +0,0 @@ -# Plan — Docs Phase 1 + Phase 2 (nav fold + generated tests in the build) - -Approved-pending. Continues the docs overhaul ([docs/backlog/docs-system-overhaul.md](../../backlog/docs-system-overhaul.md)) on the `next-iteration` branch, on top of the Phase 0 commit. Phases 1 and 2 land together (Phase 1 is mostly already done by Phase 0's nav, so it's a small finish folded into the higher-value Phase 2). - -## Phase 1 — finish the audience-split nav (small, mostly done) - -Phase 0's `nav:` already splits User guide vs Developer reference and excludes `history`/`backlog`. The remaining Phase-1 work: -- **Group the "Tests" nav section under the user-facing top level** (it currently sits between Effects and Developer reference — confirm that's the right spot for an end-user path; likely yes). -- **No file moves.** Phase 1 was always nav-only. If the current nav already reads top-down cleanly, Phase 1 is declared done with at most a label/ordering tweak — do NOT invent restructuring to justify the phase. - -## Phase 2 — fold test-doc generation into the build + surface tests per module - -Two deliverables, both leaning on existing infrastructure (`moondeck/docs/_test_metadata.py` already parses tests and feeds both the doc generator and MoonDeck; `render_unit_tests()`/`render_scenarios()` in `generate_test_docs.py` are importable pure functions; `cases_for_module()`/`paths_for_module()` already return per-module test data). - -### 2a — generate the test pages at build time (stop committing them) -- Add **`mkdocs-gen-files`** (standard MkDocs plugin) with a small `moondeck/docs/gen_pages.py` hook that, at `mkdocs build` time, calls the existing `render_unit_tests(collect_unit_files())` / `render_scenarios(collect_scenario_files())` and writes `tests/unit-tests.md` + `tests/scenario-tests.md` into MkDocs' virtual file tree. -- **Delete the committed `docs/tests/unit-tests.md` + `docs/tests/scenario-tests.md`** (~25K words leave the repo — pure subtraction). They regenerate fresh on every build. -- **Retire the commit-time drift gate** for these files: `generate_test_docs.py --check` (CLAUDE.md Event 1 context) and any CI drift check become unnecessary — the pages can't drift when they're built from source every time. Keep `generate_test_docs.py` itself (CLI still useful for a quick local look, and MoonDeck may reference it), but it no longer *writes into the repo* as the source of truth for the site. Decision to settle in review: keep the CLI writing to `docs/tests/` for non-site consumers, or make it print-only. Leaning: keep it writing (MoonDeck/CLI convenience) but drop the CI `--check` gate, and gitignore `docs/tests/*.md` so a stray local run doesn't dirty the tree. -- Add `mkdocs-gen-files` to `build_docs.py`'s inline PEP-723 deps and the CI docs build. - -### 2b — "Tests proving this works" on each module page -- A `mkdocs-gen-files` (or a macro via `mkdocs-macros-plugin`) step that, for each module page, injects a **Tests** section listing the unit cases + scenarios that exercise it, sourced from `cases_for_module(name)` / `paths_for_module(name)`. -- This **replaces the hand-authored `[Tests](../../../tests/unit-tests.md#foo)` links** in effects.md/modifiers.md — which were the brittle, drift-prone anchors we just fixed by hand. Auto-generated from metadata, they can't rot: a module with no test shows "no tests yet" (honest), a module with tests shows them. Delivers the PO's "a GitHub issue is solved by adding a test, visible to end users." -- Scope decision for review: inject into the *rendered* page only (via gen-files/macros, source `.md` stays clean) vs. write into the source `.md`. **Leaning: rendered-only** — keeps the source `.md` free of generated blocks (consistent with 2a's "generated, not committed" principle) and avoids a new drift class. - -## Why together, why now -- Phase 1 is too small to be its own change; it's nav polish that belongs with the next docs commit. -- Phase 2 is the highest value-to-effort remaining phase and is **subtraction** (deletes 25K committed words + a CI gate + the hand-authored test links). It builds directly on the 24 tests just added. -- Continues on `next-iteration` (PO's workflow: exercise the branch before merging). No dependency on Phase 0 being *merged* — only on its files, which are on the branch. - -## Files -- **New:** `moondeck/docs/gen_pages.py` (the mkdocs-gen-files hook). -- **Edit:** `mkdocs.yml` (add `gen-files` [+ `macros` if 2b uses it] plugins; nav tweak), `moondeck/docs/build_docs.py` (inline dep), `.gitignore` (`docs/tests/*.md`), `docs/moonmodules/light/effects/effects.md` + `modifiers/modifiers.md` (drop the hand `[Tests]` links, replaced by the injected section), CLAUDE.md (drop the test-doc `--check` gate note), `docs/testing.md` (describe build-time generation). -- **Delete:** `docs/tests/unit-tests.md`, `docs/tests/scenario-tests.md` (regenerated at build). - -## Verification -- `uv run moondeck/docs/build_docs.py` builds; `tests/unit-tests.html` + `tests/scenario-tests.html` exist in `site/` (generated, not from committed source). -- Each effect/modifier page shows its Tests section; a no-test module shows "no tests yet" (none should, after the 24 additions). -- 0 broken anchors; the 254 intentional out-of-`docs/` source-link warnings unchanged (Phase-4 marker). -- ctest/scenarios/spec-check still green (Phase 2 touches no C++; the deleted `.md` were generated artifacts). -- MoonDeck's test view (which uses the same `_test_metadata.py`) still works — shared parser untouched. - -## Out of scope (later phases, separate go-ahead) -- Phase 3 (de-dup facts via snippet-includes), Phase 4 (Doxide source drill-down). diff --git a/docs/history/plans/Plan-20260702 - Docs Phase 3 (drift validation, not snippet de-dup).md b/docs/history/plans/Plan-20260702 - Docs Phase 3 (drift validation, not snippet de-dup).md deleted file mode 100644 index ee059ad4..00000000 --- a/docs/history/plans/Plan-20260702 - Docs Phase 3 (drift validation, not snippet de-dup).md +++ /dev/null @@ -1,57 +0,0 @@ -# Plan — Docs Phase 3: drift validation (not snippet de-duplication) - -Approved-pending. Continues the docs overhaul ([docs/backlog/docs-system-overhaul.md](../../backlog/docs-system-overhaul.md)) on `next-iteration`, on top of the Phase 1+2 commit. - -## The pivot: the backlog's premise was wrong - -Phase 3 in the backlog assumed `pymdownx.snippets` (`--8<--`) could single-source facts by pulling them from the `.h` into the `.md`. **Investigation shows it can't** — snippets pull text *verbatim*, and the `.h` / `.md` don't hold the same *text*, they hold the same *fact in two forms for two audiences*: - -- `.h`: `controls_.addUint8("cooling", cooling, 1, 255)` — code + machine range, for the compiler/developer. -- `.md`: `` `cooling` — how fast heat dissipates… `` — human prose, for the reader. - -A snippet can't turn `addUint8(…,1,255)` into "how fast heat dissipates", and pulling the raw comment/code into prose drags `//` delimiters and code syntax that read wrong. So the naive "include the `.h`" plan is off the table. - -What the audit found instead — two buckets: - -- **Bucket A — true duplication, worth guarding:** control **numeric ranges** (~50, hand-copied `.h`→`.md`) and attribution **URLs** (~51, the same GitHub URL in `.h //Author:` and `.md Origin:`). No sync today; a range/URL can change in code and the doc silently drifts. -- **Bucket B — NOT duplication, leave alone:** control *names* (already validated by `check_specs`), and architectural facts (audience-aware restatements — `architecture.md` = the principle, module `.md` = this module's behaviour, `.h` = the implementation). De-duping these would *harm* readability; they're working as designed. - -## Decision (PO): validate, don't generate - -For both Bucket-A types: **catch drift with a gate, keep the prose hand-authored.** Not build-time generation (which mixes generated + authored content per line and is more fragile). The human prose stays readable; a check fails when the `.h` and `.md` disagree, so "change one, forget the other" is caught at commit instead of shipping. - -This is the pragmatic Phase 3: it solves the actual "change one thing, the other drifts" pain directly, at low risk, without forcing code-into-prose. - -## Deliverables - -Extend `moondeck/check/check_specs.py` (the existing spec-drift gate — already parses `.h` control names and `.md` prose) with two new drift checks: - -### 3a — control-range drift -- Parse each control's numeric bounds from the `.h` (`addUint8("floor", floor, 0, 255)` → `floor: 0–255`; also `addInt`, `addUint16`, etc.). -- For each control that HAS a range in the `.h`, check the module's `.md` prose mentions BOTH bounds (as `0–255`, `0-255`, `(0…255)`, `1 to 8`, etc. — tolerate the common human spellings). -- **Warn (not hard-fail) on drift** — the range appears in the `.h` but not (or differently) in the `.md`. Reason: some controls legitimately don't restate the range in prose (a pin list, an enum); a hard-fail would force noise. A warning surfaces real drift without blocking. (Settle in review: warn vs a hard-fail with an opt-out list.) -- Report format: `MODULE.md — control 'floor' range 0–255 (from .h) not found in prose`. - -### 3b — attribution-URL drift -- Parse the URL(s) from each `.h` `// Author:` line. -- Check the same URL appears in the module's `.md` `Origin:` line (the `.md` wraps it in a markdown link; match on the bare URL substring). -- **Warn on drift** — a URL in the `.h` Author line absent from the `.md`. Report: `MODULE.md — author URL <url> (from .h) not in Origin line`. - -### Wiring -- These run inside `check_specs.py` (already a commit gate — CLAUDE.md Event 1, gate 1), so no new gate, no CI change. The catalog pages route their controls through the table hook, but the *source* prose the checks read is the `.md`, unchanged. -- `docs/testing.md`: document the two new drift checks under the spec-check description. - -## Explicitly OUT of scope (and why) -- **Snippet-including `.h` into `.md`** — can't bridge code↔prose (the pivot above). -- **Generating ranges into the doc** — PO chose validate-only; keeps prose readable. -- **Control names / architectural facts** — Bucket B, not duplication; de-dup would harm. -- **Phase 4 (Doxide)** — separate, later. - -## Verification -- `check_specs.py` runs clean on the current tree, OR reports genuine drift (which we then fix in the `.md` — surfacing existing drift is a *feature* of landing this). -- The new checks are unit-tested where practical (a `test/python` case feeding a synthetic `.h`+`.md` pair, asserting drift is caught) — matches the host-side test tier. -- No change to rendered docs (checks only); catalog tables/build unaffected. - -## Files -- **Edit:** `moondeck/check/check_specs.py` (two drift checks), `docs/testing.md` (document them), any `.md` where the checks surface real drift (fix the prose). -- **Maybe new:** `test/python/test_check_specs_drift.py` (unit-test the new checks). diff --git a/docs/history/plans/Plan-20260702 - Docs Phase 4a (source snippets, Doxide next).md b/docs/history/plans/Plan-20260702 - Docs Phase 4a (source snippets, Doxide next).md deleted file mode 100644 index 6f73011a..00000000 --- a/docs/history/plans/Plan-20260702 - Docs Phase 4a (source snippets, Doxide next).md +++ /dev/null @@ -1,54 +0,0 @@ -# Plan — Docs Phase 4a: source snippets now, Doxide next commit - -Approved-pending. Final phase of the docs overhaul ([docs/backlog/docs-system-overhaul.md](../../backlog/docs-system-overhaul.md)), on `next-iteration`, after Phase 3. - -## The split (PO decision) - -The goal is *the `.h`/`.cpp` files are the basis for documentation*. Two ways to get there: - -- **Doxide** (the backlog's Phase 4) renders full `.h` API docs into the site — but it's a **from-source C++ binary** (can't use `uv`; needs a CMake CI build), and our 139 `.h` files use **plain `//` prose**, not the Doxygen `///`/`@param` style Doxide reads richly. Heavy on two fronts. -- **`pymdownx.snippets`** (already enabled, uv-native) embeds **real source excerpts** into a doc — no new tool, no comment conversion. - -**PO wants Doxide eventually** (it's the fuller realisation of "source as the doc basis") but agrees to try snippets first. So: - -- **This commit — Phase 4a (snippets):** targeted source-embeds where the source genuinely *is* the spec. -- **Next commit — Phase 4b (Doxide):** given its own isolated commit to evaluate the from-source CI build + comment-style question on real output, abort-clean if it doesn't pay. - -**Why this order helps 4b** (the "does 2 make 3 easier?" question): snippets don't share CI/build plumbing with Doxide, but they *de-risk the decision* — we see how our plain-`//` comments read when surfaced, learn which `.h` regions are doc-worthy (the same regions Doxide would document), and decide the comment-conversion scope with eyes open instead of mid-build. Concrete-first. - -## Phase 4a deliverables (snippets) - -Pick **targets where the source is the authoritative spec and the doc currently hand-copies it** — so embedding removes a real drift risk (the same class Phase 3 guards, solved by making source *be* the doc). Not blanket; a handful of high-value cases: - -1. **Improv frame constants** — `ImprovProvisioningModule.md` hand-documents `0x01`/`0x03` frame types + the magic; embed the real `ImprovFrameType` enum + `kImprovMagic`/`kImprovMaxPayload` from `src/core/ImprovFrame.h` (mark the region `// --8<-- [start:frame-constants]` / `[end:...]`). -2. **Preview wire format** — `PreviewDriver.md` hand-writes `[0x03][count:u32]…` layouts; embed the real header-packing constants / the frame-type bytes from `src/light/drivers/PreviewDriver.h` where they're defined in code. -3. **(If clean) one more** — a control-enum or a small struct another spec hand-copies. Only where it reads well; stop at 2–3. - -Mechanism: add `// --8<-- [start:name]` / `[end:name]` markers around the source region (a comment the compiler ignores), and `--8<-- "src/…/File.h:name"` in the `.md`. `base_path` is already `$config_dir` (repo root), so `src/…` resolves. - -For each embed, **note what we learned** for 4b: did the plain `//` comment around it read well as doc, or would it need a richer comment? (This is the 4b input.) - -## Phase 4b (Doxide) — spec for the NEXT commit (not built now) - -Recorded so it's ready to evaluate: -- **Install:** Doxide is `git clone --recurse-submodules` + CMake build (C++ toolchain + LibYAML). CI options: build-and-cache the binary in the deploy-pages job, or vendor a prebuilt Linux binary. NOT `uv` — the one place the uv-everywhere rule bends (like the ESP-IDF Python exception; justify at the introduction site). -- **Comments:** decide from 4a's findings how much of the 139 `.h` to give Doxide-style comments. Likely **start with `core/`** (the stable base), a handful of files, evaluate the rendered output, then decide whether to sweep the light domain. Do NOT convert all 139 up front. -- **Integration:** Doxide emits Markdown → into a `Developer / Source` nav section of the existing Material site. The out-of-docs `../src/*.h` links (currently rewritten to GitHub by mkdocs_hooks.py) get repointed at the in-site Doxide pages where they exist. -- **Abort criteria:** if the CI build is too fragile or the rendered output doesn't beat the GitHub source links, drop it — 4a already delivers a lighter version of the goal. - -## Verification (4a) -- The embedded snippets render as fenced code in the doc; the values match the source (because they *are* the source). Editing the `.h` constant changes the rendered doc on next build — the single-source proof. -- `check_specs.py` + docs build stay green; no rendered-doc regressions elsewhere. -- The hand-copied `[0x03]…` prose that the snippet now covers is trimmed to a one-line pointer (the code block is the authority) — net subtraction where it applies. - -## Files (4a) -- **Edit:** the 2–3 source `.h` (add `--8<--` markers, comment-only), the 2–3 `.md` (replace the hand-copied block with the snippet include + trim surrounding prose), `docs/backlog/docs-system-overhaul.md` (mark Phase 4 split). -- **New:** this plan. - -## What 4a shipped + the 4b learnings (recorded 2026-07) - -Two targeted embeds landed: -1. **ImprovFrame.h `:frame-constants`** → ImprovProvisioningModule.md. Real `ImprovFrameType` enum + magic/version/payload constants. The source *is* the authority; renders as clean, syntax-highlighted C++. **This is the ideal case** — actual code constants an integrator needs verbatim. -2. **PreviewDriver.h `:wire-format`** → PreviewDriver.md. The `[0x03][count:u32]…` layout — but it lives in a `//` **comment block**, not code. It renders with the `//` prefixes intact (correct — it *is* source), acceptable in a `cpp` fence but visibly "a source comment," and the doc's richer behavioural prose stays around it. - -**The 4b (Doxide) input:** our plain `//` comments surface as literal commented source. For real code (enums/constants/structs) that's great. For prose-in-comments it's serviceable but shows why Doxide's structured comments would render cleaner — so **4b's comment-conversion effort is real but bounded**: the highest-value Doxide output is on the *code* (signatures, types, constants), which needs little/no comment change; the prose polish is optional. Start Doxide on `core/` where the types are the payload, evaluate, decide on the light domain. diff --git a/docs/history/plans/Plan-20260702 - Docs Phase 4b (Doxide pilot, attempted, abandoned).md b/docs/history/plans/Plan-20260702 - Docs Phase 4b (Doxide pilot, attempted, abandoned).md deleted file mode 100644 index 2d6b394c..00000000 --- a/docs/history/plans/Plan-20260702 - Docs Phase 4b (Doxide pilot, attempted, abandoned).md +++ /dev/null @@ -1,94 +0,0 @@ -# Plan — Docs Phase 4b: Doxide pilot (evidence-first) - -Approved-pending. The autodoc phase of the docs overhaul ([docs/backlog/docs-system-overhaul.md](../../backlog/docs-system-overhaul.md)), on `next-iteration`. - -## What the Doxide evaluation actually showed (built + ran it, 2026-07) - -Before planning, Doxide was **built from source and run on real projectMM headers**. Findings — these ground the whole plan: - -1. **Doxide renders ONLY Doxygen-commented entities.** Given our plain `//` comments it produced an empty page. A `struct` with a `/** */` comment produced a clean per-class Markdown page (member table, Material admonitions) — genuinely nice, MkDocs-integrated output. **So Doxide needs the comment style converted to `/** */` / `///` first.** -2. **Its Tree-sitter parser choked on real C++20** on the first core file — `auto** newArr = new MoonModule*[n]` and `(c->*fn)()` both "parse error, will continue". Doxygen wouldn't. The own-parser fragility is real and showed up immediately. -3. **Adoption is near-zero** (author's own projects; single maintainer; ~1.3k stars). Low lock-in though — output is Markdown, so if Doxide dies we keep the `.md`. - -Conclusion: a full Doxide rollout (convert ~139 headers, risk parser failures across the codebase) is high-cost, high-risk, and premature. But the *output quality on clean, commented code is good*. So: **pilot it on a small, clean, comment-converted subset, in-site, and decide expansion on the real result** — not on reputation, not on a blanket sweep. - -## Pilot scope (deliberately minimal) - -**Convert 2–3 clean `core/` headers to Doxygen-style comments and render them into the site with Doxide. Nothing else.** No CI wiring yet (local build only for the pilot); no touching the 139; no C++20-heavy files. - -Candidate headers (verified clean of the constructs that choked the parser — no `auto**` / pointer-to-member): `ImprovFrame.h`, `AudioFrame.h`, `AudioLevel.h`, `Control.h`, `Scheduler.h`. Pick **2–3** that are genuinely useful to see as API docs (leaning `ImprovFrame.h` — already snippeted, so we compare snippet-vs-Doxide on the same file — plus `Control.h` or `Scheduler.h`). - -### Steps -1. **Comment conversion (bounded).** On the 2–3 pilot headers only, convert the class/struct/key-method `//` comments to `/** */` / `///` where it adds value. Keep it light — the parser reads the *structure*; a one-line `/** */` per public entity is enough to start. Note the diff size as the per-file cost signal for a future sweep. -2. **`doxide.yaml`** at repo root (or `docs/`) listing just the pilot files, `output:` into a `docs/api/` (or a virtual dir). -3. **Local render + eyeball.** Build with the local Doxide binary; inspect the generated `.md` in the Material site (drop it under a `Developer / Source (pilot)` nav entry). Compare to: (a) the GitHub source link, (b) the Phase-4a snippet, on `ImprovFrame.h`. -4. **Assess against criteria (below); write the verdict into this plan.** Decide: expand (and how far), or stop at pilot / drop. - -### NOT in the pilot -- CI integration (the from-source CMake build in the ubuntu deploy-pages job) — deferred until the pilot proves the output is worth it. If the pilot passes, CI wiring is its own step (build-and-cache the binary, or vendor a prebuilt Linux binary — the one justified uv-exception, like ESP-IDF). -- Converting more than 3 headers. -- Any C++20-heavy / template-heavy header (parser risk). - -## Decision criteria (write the verdict here after the pilot) -- **Output quality:** does the rendered API page beat the GitHub source link for a reader? (If not → drop; the links already work.) -- **Comment cost:** how invasive was the `/** */` conversion per file? Extrapolate to 139. -- **Parser robustness:** did it choke on any pilot header? How often would it across the codebase? -- **CI feasibility:** is the from-source build cacheable/reliable enough to add to deploy-pages? - -Only if all four clear does a wider rollout make sense. A likely outcome given the evaluation: **Doxide for `core/` (stable, clean, the API integrators care about), snippets for the rest** — a hybrid, not a blanket conversion. - -## Files (pilot) -- **Edit:** 2–3 `src/core/*.h` (comment conversion), `mkdocs.yml` (a pilot nav entry + the generated dir), maybe `.gitignore` (generated `docs/api/`). -- **New:** `doxide.yaml`, this plan. -- **Note:** the built Doxide binary lives at `/tmp/doxide-eval/build/doxide` for the local pilot (not committed). - -## VERDICT (pilot ran 2026-07, on ImprovFrame.h) - -Converted 4 comment blocks (`//` → `///`, **11 lines, no rewrite**), ran Doxide, staged its Markdown into our real Material site, screenshotted the rendered page. Against the four criteria: - -1. **Output quality — GOOD.** The `ImprovFrameParser` page renders natively in *our* Material site: our theme/nav/search/favicon, a member table, the method signature in a blue Material "function" admonition, the prose comment beneath. This is genuinely nicer than the GitHub source link for *reading the API surface* (types, signatures, what each does) — and it's in-site, the Phase-0 one-site vision realised. ✅ -2. **Comment cost — LOW per file, but multiply by 139.** 11 lines for one header, purely mechanical (`//`→`///` above each documented entity; inline `//` on members needs `///<`). Cheap per file, but a full sweep is still ~139 files of mechanical edits — bounded, not free. -3. **Parser robustness — MIXED (the real caveat).** Two failures found: (a) on the FIRST core file (MoonModule.h) the Tree-sitter parser errored on `auto** x = new T*[n]` and `(c->*fn)()` and skipped them — Doxygen wouldn't. (b) **Enum *values* and their inline `//` comments do NOT render** — ImprovFeedResult's four values + their meanings were dropped; only the enum's own comment shows. For an enum-heavy codebase that's a real gap (the values are often the point). Doxygen with `///<` trailing comments would show them. -4. **CI feasibility — deferred, but the binary built clean** (cmake + libyaml, ~1 min). Not yet wired; if adopted, build-and-cache in deploy-pages or vendor a prebuilt Linux binary (the one justified uv-exception). - -**Conclusion: Doxide is viable and the output is good, but with two known gaps (parser skips some C++20; enum values don't render).** The hybrid the plan predicted is confirmed the right shape: **use Doxide for the stable, clean, class/struct-shaped `core/` API where it renders well, and keep `pymdownx.snippets` for enum-value tables / wire-format constants / anything Doxide drops.** Not a blanket 139-file conversion. - -**Recommendation for the actual Phase-4b commit (next):** wire Doxide into CI for a curated `core/` file list (start ~5–8 clean headers), converting only those; add the `Developer / Source` nav section; keep the snippets for the enum/constant cases. Grow the file list as headers are touched, per the "existing em-dashes replaced as files are touched" incremental pattern — never a big-bang sweep. The pilot itself is reverted (the `///` on ImprovFrame.h can stay as harmless — it's valid and already improves the source-doc quality). - -### Second pilot: a CATALOG item (GameOfLifeEffect.h) — Doxide adds nothing here - -Ran Doxide on a catalog effect too. Result: **near-empty page, zero value, and it sharpens the scope boundary.** The generated `GameOfLifeEffect` page showed only the class name + class comment — no controls, no methods, no state. Three reasons, all structural: -1. Public members carry plain `//` (or no) comments → Doxide skips them. -2. The parser choked on `uint8_t backgroundColorR = 0, backgroundColorG = 0, …` — **comma-separated declarations on one line**, very common C++ — 4 parse errors, members skipped. -3. **The user-facing surface of a catalog effect is its CONTROLS, which are `controls_.addX(...)` *calls inside `onBuildControls()`* — a method body, not declarations. No static doc tool (Doxide or Doxygen) can see them.** Our own card generator reads those same calls and *is* the only thing that surfaces them. - -So for every catalog item the card already carries what a reader wants (description, controls+ranges, GIF, source link), and Doxide would produce a title-only page that duplicates the card and adds nothing — pure cost. - -**Sharpened boundary:** Doxide is for the **services & infrastructure** layer only — the class/struct/enum-shaped `core/` and light-*base* code whose declarations *are* the API (parsers, buffers, the module base, wire-format types). It is **NOT** for the catalog module types (effects/modifiers/layouts/drivers): those are documented by their cards (generated from the runtime `controls_.add` calls), which no static tool can replicate. This maps exactly onto the two-module-kinds distinction already in coding-standards.md § Documentation model. - -## Third + fourth pilots: cxxdox and moxygen — the tool choice, decided - -After Doxide's Tree-sitter parser choked on ordinary C++20 (comma-separated declarations, `auto**`, pointer-to-member) and dropped enum values, two robust-parser alternatives were piloted on the same two files. - -**cxxdox** (`mkdocs-cxxdox`, libclang, an MkDocs plugin): configured + loaded correctly, but **couldn't render on macOS** — it ships prebuilt wheels for Linux + Windows only (bundled libclang 21), no mac wheel, and its `cindex.py` is version-locked to libclang 21 (mac has clang 17/22). CI (ubuntu, Linux wheel) would work and be uv-installable, but local mac dev breaks — losing the "same build locally and in CI" property. Also: libclang needs a **real compile context** (sysroot + full `-I` set) to parse a header, heavier than syntax-only tools. Adoption: v0.1.6, essentially KFR-only — the weakest of all. - -**moxygen** (real Doxygen → XML → Markdown, a Node tool) — **the clear winner:** -- **Doxygen parsed our C++20 with ZERO errors** — ate exactly the comma-declarations Doxide choked on. 17 XML files, both the core parser AND GameOfLifeEffect (the catalog file Doxide gave nothing for). -- **Rendered the FULL class** — every member + method + inheritance + source line — and **enum values in a table** (`CurrentState`/`Rpc`/… — the exact thing Doxide dropped). -- **Worked from our plain `//` comments** (`JAVADOC_AUTOBRIEF` + `EXTRACT_ALL`) — **no comment conversion needed** for structure (add `///`/`///<` later to enrich descriptions). -- **Rendered cleanly in our Material site** — Material tables, our theme/nav. -- Doxygen is the **de-facto-standard parser** (25 years, LLVM/Qt/…) — best on the *Common patterns first* axis of any candidate. -- **Cost:** a two-step pipeline (Doxygen binary + the `moxygen` npm tool). Neither is uv-native — Doxygen is a brew/apt binary, moxygen is `npx`. But both are trivially available on CI (`apt install doxygen`, `npx moxygen`), and Doxygen needs *less* per-file compile-context wrangling than libclang. - -### DECISION: moxygen (Doxygen → Markdown) for Phase 4b - -It's the only option that is simultaneously robust (zero parse errors on our real code), complete (enum values + full members, incl. the catalog file), recognizable (Doxygen is THE standard), and MkDocs-native (Markdown output into our one site). The cost is a Doxygen + Node CI dependency — heavier than uv, but standard and reliable, and justified at the introduction site like the ESP-IDF Python exception. - -**Still true regardless of tool:** autodoc is for **services & infrastructure** only. For catalog module types the card remains the doc (controls are runtime `add()` calls no static tool sees). moxygen *can* show a catalog class's raw C++ members — useful to a developer — but that's a *secondary* developer view, not the user-facing card. - -### Phase-4b implementation shape (next commit) -- CI: `apt install doxygen` + `npx moxygen` in the deploy-pages job (and a local `moondeck/docs/` wrapper so `build_docs` can run it where doxygen is present; skip gracefully where it isn't, like cxxdox's local-mac gap — but moxygen degrades better since doxygen is brew-installable on mac). -- A `Doxyfile` scoped to a curated `services & infrastructure` file list (core/ + light-base), `GENERATE_XML`, `EXTRACT_ALL`, `JAVADOC_AUTOBRIEF`. -- moxygen → `.md` into a `Developer / Source` MkDocs nav section. -- Enrich the highest-value headers' comments with `///`/`///<` incrementally (as files are touched), but structure works from plain `//` on day one. -- Keep `pymdownx.snippets` for the wire-format/constant embeds already in place. diff --git a/docs/history/plans/Plan-20260702 - Docs Phase 4b (source-generated technical docs, superseded by Docs v2).md b/docs/history/plans/Plan-20260702 - Docs Phase 4b (source-generated technical docs, superseded by Docs v2).md deleted file mode 100644 index 0210cf92..00000000 --- a/docs/history/plans/Plan-20260702 - Docs Phase 4b (source-generated technical docs, superseded by Docs v2).md +++ /dev/null @@ -1,66 +0,0 @@ -# Plan — Phase 4b: source-generated technical docs (the inversion) — SUPERSEDED by Docs v2 - -**Outcome: superseded.** The core idea (generate technical docs from `.h` `///` comments via Doxygen→moxygen) shipped, but this plan's *page model* — a curated `INFRA_HEADERS` list feeding virtual `moonmodules/api/` pages — was replaced by the Docs v2 plan: exhaustive per-header discovery, domain-nested `moonmodules/{core,light}/moxygen/` output, and the two-surface (summary + generated) structure. Kept as the design record of the intermediate step. - -Approved-pending. The final phase of the docs overhaul ([docs/backlog/docs-system-overhaul.md](../../backlog/docs-system-overhaul.md)), on `next-iteration`, after the Doxide→moxygen tool evaluation (see the pilot plan for why moxygen won). - -## The inversion (PO directive) - -**Prerequisite: all *technical* documentation is generated from source code.** Turn the reasoning around — the source `.h` is the single home of technical content; the site's technical pages are *generated views* of it. No hand-written `.md` restates what a `///` comment can carry. - -This extends the model already proven for **catalog** modules (effects/modifiers/layouts/drivers): their cards are generated (from the prose `### ` blocks + the runtime `controls_.add` calls) by `mkdocs_hooks.py`. Now **infrastructure** modules (core services + light-base) get the same treatment via **moxygen** (Doxygen → Markdown), generated at build time by the same hook. - -### The two module kinds, both source-generated (unchanged distinction, now symmetric) - -- **Catalog module types** (effects/modifiers/layouts/drivers): end-user **cards**, generated from the catalog `.md` prose blocks + the `controls_.add` calls. The card is the whole story; a `⌄ details` anchor + links sit in the Links column. **Unchanged** — already source-driven. -- **Services & infrastructure** (core `*Module`, light-base `Layer`/`Buffer`/`MappingLUT`/…): a **generated API page** per module, produced by Doxygen+moxygen from the `///` comments in the `.h`, at build time. This is the new work. - -## Build-time generation (not runtime — MkDocs is static) - -MkDocs → flat HTML on GitHub Pages; no server, so no click-time generation. Same outcome at **build time**: `mkdocs_hooks.py` runs Doxygen+moxygen during the build and injects each infra module's generated API page into the virtual file tree (exactly how it already injects `tests/unit-tests.md`). The card / overview links point to those pre-generated pages. To the reader: click the link → see the generated doc. Identical UX, works on static Pages. - -## Deliverables - -### 1. Generated infra API pages (the core inversion) -- Extend `mkdocs_hooks.py`: an `on_files` step that runs Doxygen (a `Doxyfile` scoped to a curated **infrastructure file list** — start with core/ + light-base, ~24 files, excluding catalog effects/modifiers/layouts/drivers) → moxygen (with the **compact custom template** from the pilot: no path leak via `STRIP_FROM_PATH`, no `--anchors`, member tables not duplicated) → inject the resulting `.md` under `moonmodules/api/<Module>.md` into the virtual tree. Generated fresh each build; never committed. -- Graceful skip where `doxygen`/`npx` are absent (like the installer-staging serve-only guard) so a contributor without doxygen can still build the rest of the site — the API pages just don't generate locally (they do in CI). Log clearly. -- CI: `apt install doxygen` in the deploy-pages job; `npx moxygen` needs Node (already on the runner). The one justified non-uv exception (like ESP-IDF), stated at the introduction site. - -### 2. Retarget `.h` links → generated API pages -- The card's `[.h]` link (currently rewritten to a GitHub blob URL by `_rewrite_out_of_docs_links`) instead points to the module's **generated API page** where one exists (`moonmodules/api/<Module>.html`), falling back to the GitHub blob URL for files with no generated page. So "click .h" → the in-site generated reference, not raw GitHub. -- Same for the per-module overview pages' source links. - -### 3. Prior art cleanup (PO directive) -- **projectMM v1/v2 prior art: removed entirely** (already done across 13 docs — verify none remains, incl. any that crept back). -- **MoonLight prior art: kept but demoted** — it lives only in the **end-user-facing** overview/card `.md` (as an `Origin:`/lineage line), NOT in the technical/generated layer. The generated API pages carry no prior art (Doxygen doesn't emit it; good). - -### 4. Enrich Control.h + FireEffect.h to "perfect documentation" (the pilot's finish) -- Fold the current `Control.md` / FireEffect-card *technical* content into `///`/`///<` comments in the source, so the generated page is complete enough that a developer reads it and thinks "I understand exactly what this does." (Started in the pilot — the `ControlType` value table already generates from `///<`.) -- What CAN'T move to source stays in a thin overview `.md`: cross-file design rationale (Control's Memory/Persistence/Design sections), the 4-column Type×Storage×UI×DMX *matrix* (a format moxygen's flat enum table can't produce — keep as a hand-authored table in the overview), and the MoonLight lineage. -- FireEffect: its description folds into the class `///`; the **card keeps** the GIF + control ranges/user-descriptions (runtime `add()` calls moxygen can't see — catalog stays card-first). - -## Where each kind of content lives (PO: `///` in the `.h` FIRST, `.md` only as secondary) - -The ordering is strict — push everything into the source that can go there: -1. **The module's overview/description → the class `///` comment.** It generates into the API page AND shows on IDE hover. This is the primary home, not a `.md`. -2. **Per-entity technical content** (methods, enums + values, signatures, ranges) → `///`/`///<` in the `.h`. Generated. -3. **A hand-written `.md` ONLY as a last resort**, for what genuinely cannot live in one source file: - - **Cross-file design rationale** (module interactions, buffer-lifecycle coupling) — no single `.h` owns it. - - **Format-specific tables** moxygen can't emit (Control's 4-column Type×Storage×UI×DMX matrix). - - **End-user prose + MoonLight lineage** — the card / a thin overview. - A module whose entire story fits in `///` comments has **no `.md`** — just its generated API page. - -## Files -- **Edit:** `moondeck/docs/mkdocs_hooks.py` (Doxygen+moxygen generation + link retargeting), `mkdocs.yml` (nav for the generated API section; moxygen template path), `.github/workflows/release.yml` (`apt install doxygen`), the curated infra `.h` files (progressively `///`-enrich, starting Control.h + a few), the shrunk infra overview `.md` (Control.md → cross-file-only + matrix + lineage), `docs/coding-standards.md` (§ Documentation model: record the inversion + the moxygen mechanism, fill the "autodoc TBD" placeholder), CLAUDE.md pointer. -- **New:** a committed `Doxyfile` (scoped, compact settings) + the moxygen custom template under `moondeck/docs/`. - -## Verification -- Build generates an API page per infra module; a card `.h` link lands on it in-site; `Control` page reproduces Control.md's type reference from `///<`. -- Doxygen absent locally → build still succeeds (API pages skipped, logged); present in CI → pages appear. -- No v1/v2 anywhere; MoonLight only in end-user `.md`. -- Enriched `.h` compile clean (`///` inert); ctest green. -- A developer reviewing the generated Control + FireEffect pages judges them complete. - -## Staged rollout (not big-bang) -- **This commit:** the generation machinery + retargeting + Control.h/FireEffect.h enriched + Control.md shrunk, as the proof. A *curated* infra file list (Control, the frame types, a couple of light-base), not all 24. -- **Later, incremental:** `///`-enrich more infra headers as they're touched (the "replaced as files are touched" pattern), growing the generated set; shrink each infra overview `.md` to cross-file-only as its source gets enriched. diff --git a/docs/history/plans/Plan-20260702 - Docs site Phase 0 (MkDocs Material).md b/docs/history/plans/Plan-20260702 - Docs site Phase 0 (MkDocs Material).md deleted file mode 100644 index df9bb21e..00000000 --- a/docs/history/plans/Plan-20260702 - Docs site Phase 0 (MkDocs Material).md +++ /dev/null @@ -1,50 +0,0 @@ -# Plan — Docs site Phase 0 (MkDocs Material at Pages root) - -Approved 2026-07-02. The full multi-phase design study lives in [docs/backlog/docs-system-overhaul.md](../../backlog/docs-system-overhaul.md); this plan is **only Phase 0** — the additive site-standup. Phases 1–4 (nav split, generated-test folding, fact de-duplication, Doxide source drill-down) each need a separate go-ahead and are not built here. - -## Goal - -End users currently read 259K words of docs as raw `.md` on github.com — no landing page, no nav, no search, because **GitHub Pages today publishes only the web installer** (`docs/install/`), never the docs. Phase 0 gives the existing docs a rendered front door with zero content changes and zero file moves. - -## What ships - -1. **`mkdocs.yml`** at repo root — Material for MkDocs, instant search, `pymdownx.snippets` (for later phases), and a `nav:` tree imposing a top-down end-user → developer order over the *existing* files. No file is moved or rewritten; `nav:` just orders what's there. `history/` and `backlog/` stay out of the published nav (internal). -2. **`docs/index.md`** — the landing page end users lack: what projectMM is → a prominent "Flash an ESP32" call-to-action routing to `/install/` → first-light → effects → developer drill-down. Preserves every affordance of the old `docs/landing/index.html` (Flash button, GitHub/Releases links) but as the docs home, with Docs/Getting-started links now resolving to *rendered* pages instead of github.com blobs. -3. **`moondeck/docs/build_docs.py`** — a PEP-723 (`# /// script`) uv wrapper around `mkdocs build`, matching the existing `moondeck/docs/` convention and the uv-everywhere rule. (As shipped: CI runs a plain `build`, which fails on missing pages / bad nav; broken links and stale anchors stay `warn`-level per the `validation:` block in `mkdocs.yml`. `--strict` is a *local* anchor-audit option, documented in `moondeck/MoonDeck.md`, not the CI gate — the intentional out-of-`docs/` source links would make `--strict` fail the build.) -4. **CI wiring** — the existing `deploy-pages` job (in `.github/workflows/release.yml`, gated to `main`) builds the MkDocs site into `pages/` root **instead of** copying the single `docs/landing/index.html`. `pages/install/` staging is untouched. Add `docs/**` + `mkdocs.yml` to the workflow's `paths:` so a docs-only change to `main` redeploys. - -## Decisions locked (PO) - -- **Scope:** Phase 0 only; later phases separately approved. -- **URL:** docs at Pages root `/`; installer stays at `/install/` (no installer links break; only `mkdocs.yml` is added to the repo — the site is assembled in CI into a throwaway dir, exactly as the installer already is, so no new repo folders). -- **Doxide comment style:** approved for the eventual Phase 4 (recorded for later). - -## Out of scope (deferred to later phases) - -- De-duplicating facts (control names, attribution, ranges) — Phase 3. -- Folding generated test docs into the build / surfacing tests on module pages — Phase 2. -- Doxide source drill-down — Phase 4. -- Any rewrite, move, or deletion of existing `.md` content. - -## Verification - -- `uv run moondeck/docs/build_docs.py --strict` builds locally with no warnings; every nav entry resolves; search index generates. -- Old `docs/landing/index.html` retired (its Flash button + links live on in `docs/index.md`); `check_specs.py` + the doc-generation `--check` still green (Phase 0 touches no specs/tests). -- CI `deploy-pages` publishes the rendered docs at `/`, installer still reachable at `/install/`. - -## Files - -- **New:** `mkdocs.yml`, `docs/index.md`, `moondeck/docs/build_docs.py`. -- **Edit:** `.github/workflows/release.yml` (build MkDocs into `pages/` root; add docs paths to `paths:`). -- **Delete:** `docs/landing/index.html` (folded into `docs/index.md`) — and drop its `docs/landing/**` from the workflow `paths:`. - -## Landed alongside: `docs/install/` → top-level `web-installer/` - -During implementation the PO asked to fix the deeper structural issue the site standup surfaced: `docs/` held three unlike things — doc-site source, a standalone web app (`install/`), and transient internal notes (`history/` + `backlog/`). Decision (PO, "go for the best, not to keep technical debt" — a young project optimizes for the end state): - -- **Moved the installer app out of `docs/`** to a top-level `web-installer/` (`git mv`, history preserved). It's an application, not documentation — the miscategorization worth fixing permanently. The **deployed URL stays `/install/`**: the release workflow maps `web-installer/` → `pages/install/`, so QR codes, deployed-device OTA URLs, and existing links keep working (zero external breakage). -- **Left `history/` + `backlog/` in `docs/`**, excluded from the site (`exclude_docs`). They're transient — slated for compaction — so relocating them would be discarded churn; keeping them out of the *published* site is all the reader-facing goal needs. - -Swept ~104 references (`docs/install/` → `web-installer/`) across scripts, both CI workflows, three check scripts, MoonDeck, JS + Python test suites, CLAUDE.md commit-gate triggers, and published docs. **Gotcha caught:** a literal-path regex misses split-component construction — `join(ROOT, "docs", "install", …)` (3 JS tests) and `"docs" / "install"` (moondeck.py) needed separate patterns; verified by running the JS/Python suites (all green). - -Deferred to a **separate next commit** (PO): top-level `scripts/` → `moondeck/` — larger orthogonal sweep, spec'd in `docs/backlog/rename-scripts-to-moondeck.md`. diff --git a/docs/history/plans/Plan-20260702 - Docs system overhaul (Phase 0 through Docs v2) (shipped).md b/docs/history/plans/Plan-20260702 - Docs system overhaul (Phase 0 through Docs v2) (shipped).md new file mode 100644 index 00000000..716695fb --- /dev/null +++ b/docs/history/plans/Plan-20260702 - Docs system overhaul (Phase 0 through Docs v2) (shipped).md @@ -0,0 +1,108 @@ +# Plan — Docs system overhaul: Phase 0 → Docs v2 (shipped) + +**Consolidated record.** This one plan replaces the nine individual docs-overhaul plans of +2026-06-30 → 2026-07-02 (Phase 0, per-type consolidation, Phase 1+2, Phase 3, Phase 4a, two Phase 4b +variants, and Docs v2). Every phase below **shipped** except the Doxide pilot, which was attempted and +abandoned — its goal (developer drill-down into the source API) was delivered instead by moxygen in +Docs v2. (The parent design study, `docs/backlog/docs-system-overhaul.md`, was itself fully shipped +and pruned per *Mandatory subtraction* — this plan is the surviving record.) + +## The arc + +Docs started as 259K words of raw `.md` read on github.com — no landing page, nav, or search, and +per-module `.md` files that hand-duplicated facts the `.h` already stated. The overhaul moved to a +**rendered site** with **two documentation surfaces per module**: a hand-written summary/catalog page +(end-user) and a technical page **generated from the `.h`** (developer). Facts now live once — in the +`.h` `///` (→ generated page) or the summary row — never a third per-module `.md`. + +The phases shipped additively (each a visible, revertible win), per *Refactor for simplicity* — never +a big-bang. + +## What shipped, by phase + +### Phase 0 — stand up the site (MkDocs Material) +`mkdocs.yml` (Material, instant search, top-down user→developer nav), `docs/index.md` landing page +(absorbed the retired `docs/landing/index.html`), `moondeck/docs/build_docs.py` (uv wrapper around +`mkdocs build`), CI `deploy-pages` builds the site at Pages root `/`, installer stays at `/install/`. +**Landed alongside:** de-overloaded `docs/` — the standalone web installer moved to a top-level +`web-installer/` (it's an app, not docs; deployed URL unchanged), `history/`+`backlog/` kept in `docs/` +but excluded from the published nav. ~104 `docs/install/`→`web-installer/` references swept. + +### Per-type doc consolidation (Stage 2 of the MoonLight migration) +~26 per-module effect/modifier/layout `.md` files → three compact-row pages (`effects.md`, +`modifiers.md`, `layouts.md`) with library *sections* inside (MoonLight / WLED / FastLED / +projectMM-native), one table row per module. `check_specs.py` moved from file-scoped to **page-scoped** +control-name validation. Drivers stayed per-file at the time (later folded into `drivers.md` + +per-driver moxygen pages by Docs v2). Library stays a *tag* + a *doc split*, never a folder axis (the +[folder-structure decision](../../backlog/folder-structure-proposal.md)). + +### Phase 1+2 — nav fold + generated tests in the build +Phase 1 (audience-split nav) was mostly delivered by Phase 0. Phase 2: the test-inventory pages +(`tests/unit-tests.md` + `scenario-tests.md`) are **generated at build time** from the test files (via +the `mkdocs_hooks.py` `on_files` hook calling `render_unit_tests`/`render_scenarios`) and **gitignored** +(`.gitignore: /docs/tests/*.md`) — ~25K committed words removed, can't drift. Each catalog card keeps a +compact one-line `[Tests]` **link** into its inventory section; an attempt to inline the full case list +per card bloated the cards and was reverted (**tests are a link, not a dump** — the deliberate 2b +outcome). + +### Phase 3 — drift validation (not snippet de-dup) +The snippet-include premise was wrong: `.h` and `.md` hold the *same fact in two forms* (code vs +prose), which `--8<--` can't bridge. The real duplication was narrower — control **ranges** (~50) and +author **URLs** (~51) restated in both places. PO chose **validate, not generate**: `check_specs.py` +gained `_check_range_drift` + `_check_author_url_drift` (block-scoped on catalog pages, tolerant of +human range spellings), pinned by `test/python/test_check_specs_drift.py`. Control *names* and +architectural facts were confirmed NOT duplication (audience-aware) and left alone. + +### Phase 4a — source snippets +`pymdownx.snippets` (`--8<--`) embeds real source into a doc where the source *is* the spec: the +`ImprovFrameType` enum + magic/payload constants (`ImprovFrame.h`) and the Preview wire-format +(`PreviewDriver.h`). Editing the `.h` constant changes the rendered doc — single-source, no drift. + +### Phase 4b — Doxide pilot: ATTEMPTED, ABANDONED +Doxide was built from source and run on real headers. It **choked** on projectMM's C++20 (Tree-sitter +parse errors on `auto**`/pointer-to-member on the first core file), renders **only** Doxygen-commented +entities (our `//` comments produced empty pages → would need converting all 139 headers), and has +near-zero adoption. High-cost, high-risk, premature — **dropped**. Its goal (in-site full source API +docs) was the same one Docs v2 then delivered by a lighter route. + +### Docs v2 — two-surface module docs (the source-generated goal, delivered) +The realisation of "the `.h` is the doc basis" — **not** via Doxide but via **moxygen** (Doxygen XML → +Handlebars templates → Markdown, in [`gen_api.py`](../../../moondeck/docs/gen_api.py)). Every module +gets a generated technical page at `docs/moonmodules/{core,light}/moxygen/<Module>.md` (gitignored, +built fresh) from its `.h` `///` comments; each catalog summary row links to it. Shipped in five stages: +1. **Machinery** — domain-nested moxygen output, module discovery from `src/{core,light}`. +2. **Template shape** — public-only reference (Handlebars denylist on private sections), `.md` on disk. +3. **Working system** — all pages generated + the summary pages built, *alongside* the old `.md` (a + committable baseline, nothing deleted yet). +4. **Optimize** — swept `///` comments module-by-module so each generated page reads as excellent + developer docs; added the summary-page control-name drift guard. +5. **Switchover** — deleted the ~30 old per-module `.md` (absorbed into `///` + summary rows), removed + the temporary migration cross-check banner, reconciled architecture.md ↔ coding-standards.md on the + two-surface model. + +## Follow-on cleanups (post-Docs-v2, same arc) +- The per-module archive `.md` (the retired detail pages parked in `<domain>/archive/` during the + migration) were validated against their generated pages and **deleted**; residual present-tense + content that outlived its `.h` was migrated into `///`, forward-looking content into `docs/backlog/`. +- **`ui.md`** (the UI *system* spec, no `.h`) was promoted to a live page + `docs/moonmodules/core/ui.md`, de-duplicated against architecture.md § Web UI + HttpServerModule. +- **Single-file catalog folders collapsed** (`light/effects/effects.md` → `light/effects.md`, etc.) — + the flat layout the folder-structure decision prescribes as groundwork for future `effects_<library>.md` + splits. +- **`check_specs.py` docPath guard** — validates every `main.cpp` `registerType` docPath resolves to a + real page + `#anchor`, so a docs rename can't silently 404 the in-UI help links (the drift that a + CodeRabbit review caught). + +## Net result +- `docs/moonmodules/` holds only summary/catalog pages + the gitignored `moxygen/` generated pages; + the ~30+ standalone per-module `.md` are gone (net doc-file subtraction). +- Every fact lives once — in the `.h` `///` or a summary row. +- The site renders at `moonmodules.org/projectMM/`; the installer at `/install/`. +- Drift is guarded at commit by `check_specs.py` (control names, ranges, URLs, docPaths). + +## What's genuinely out (not deferred work, decided-against) +- **Doxide** — abandoned (above); moxygen delivered the goal. +- **Per-library page splits** (`effects_wled.md`) — future growth, a lift-not-rewrite when a library + section outgrows its page; the flat filenames + sections are already in place for it. +- **assets/ and test/ type-splits** — the [folder-structure decision](../../backlog/folder-structure-proposal.md)'s + remaining "mirror src's domain/type shape" work; independent of the doc-content overhaul. diff --git a/docs/history/plans/Plan-20260702 - Docs v2 (two-surface module docs).md b/docs/history/plans/Plan-20260702 - Docs v2 (two-surface module docs).md deleted file mode 100644 index 67286d3a..00000000 --- a/docs/history/plans/Plan-20260702 - Docs v2 (two-surface module docs).md +++ /dev/null @@ -1,85 +0,0 @@ -# Plan — Docs v2: two-surface module documentation - -Approved-pending. Executes the redefined [§ Documentation model](../../coding-standards.md#documentation-model) (agreed first, per CLAUDE.md). On `next-iteration`, continuing the docs overhaul. - -## Goal - -Collapse every module's documentation to **two surfaces** and delete the rest: -1. a hand-written **summary page** per module *group* (end-user, a 4-column table + cross-file prose), and -2. a **generated technical page** per module (`{core,light}/moxygen/<Module>.md`, 100% from the `.h`). - -End state: the ~30 per-module standalone `.md` files are gone; each module's story lives in its `.h` (`///` → generated page) and its group summary row. This supersedes the earlier Phase-4b "infra API pages" model (virtual `moonmodules/api/`, `INFRA_HEADERS`), which becomes the general mechanism for *all* modules, retargeted to the domain-nested `moxygen/` dirs. - -## Why this ticks the boxes (sanity check vs CLAUDE.md) - -- **No duplication / Document once:** a fact lives in the `.h` **or** the summary row, never both, never a third per-module `.md`. -- **Default to subtraction:** the change *deletes* ~30 `.md` files; net-negative doc count. -- **Common patterns first:** the guide-over-generated-reference split is the docs.rs / Sphinx-autodoc / Doxygen pattern (named in the standard). -- **Complexity in core:** the generation machinery (`gen_api.py` + hook) is the one complex piece; every module then gets a page for free. - -## Inventory (what moves where) - -**Absorb into `.h` `///` + a summary row, then delete (~29 files):** -- **Core (12 + `ui.md`):** AudioModule, Control, DevicesModule, FilesystemModule, FirmwareUpdateModule, HttpServerModule, I2cScanModule, ImprovProvisioningModule, MoonModule, NetworkModule, Scheduler, SystemModule — each maps 1:1 to a `src/core/*.h`. `ui.md` has no `.h` (it documents the UI *system*, not a module) → its content folds into the core-UI summary page's prose, not a `///`. -- **Light supporting (10):** Buffer, MappingLUT, Layer, ModifierBase, Layouts, Layers, BlendMap, Drivers, EffectBase, LightConfig — each maps to a `src/light/**/*.h`. -- **Catalog detail (7):** NetworkSendDriver, RmtLedDriver, LcdLedDriver, HueDriver, ParlioLedDriver, PreviewDriver, MoonLiveEffect — already card-backed; their cross-file prose (where any) moves to a summary-page section, their `///` gets enriched, the detail `.md` is deleted. - -**Keep (the 4 catalog summary pages, restructured to link moxygen):** `light/{effects,modifiers,layouts,drivers}/<type>.md`. - -**Create (3 new summary pages, nested per the standard):** -- `core/ui/` — core UI modules (FileSystem, System+Audio+I2C, FirmwareUpdate, Network+Improv+Devices). -- `core/supporting/` — core supporting (Control, Scheduler, MoonModule, HttpServer). -- `light/supporting/` — light supporting (the 10 above). - -## Staged execution (curated proof first, then sweep) - -**Stage 1 — machinery (retarget the generator, one change):** -- `gen_api.py`: replace `INFRA_HEADERS` (flat list → `moonmodules/api/`) with a per-domain resolver that emits `moonmodules/core/moxygen/<Module>.md` and `moonmodules/light/moxygen/<Module>.md`. The header list broadens to *all* documented modules (core + light), discovered from `src/{core,light}` rather than hand-listed, so it can't drift as modules are added. -- `mkdocs_hooks.py`: `_API_MODULES` + `_rewrite_out_of_docs_links` retarget `.h` links to the new `{domain}/moxygen/<Module>.md` path (currently hardcodes `moonmodules/api/`). The domain is derivable from the module's src path. -- `.gitignore`: `docs/moonmodules/*/moxygen/` (gitignored, per the standard). -- Verify: build generates pages under both `core/moxygen/` and `light/moxygen/`; a summary link lands on one. - -Re-staged (PO): ship a **working system first** (all generated pages + summary pages, old `.md` kept), commit that as a complete baseline, *then* optimize the generated pages, and delete the old `.md` only at the very end. De-risks: a committable, coherent system exists before any enrichment or deletion. - -**Stage 2 — machinery + template shape (done):** -- **Moxygen template tuning.** Private members leaked (`Private Attributes`/`Private Methods`) because Doxygen's XML backend emits documented privates regardless of `EXTRACT_PRIVATE`. Fixed with a Handlebars denylist in `class.md` on the raw section `kind` (moxygen's own lever, no post-processing) — public-only reference. -- **`.md` on disk, standard flow.** `gen_api.generate()` writes each page to `docs/moonmodules/{domain}/moxygen/<Module>.md` (gitignored) so a human previews the `.md` directly and MkDocs discovers it as a normal source file. -- **Temporary cross-check header.** Each generated page opens with a `> _Migration cross-check (temporary):_` line linking the source `.h` and (while it still exists) the original `.md`, so a reviewer can confirm the `.md`'s content was absorbed. Removed at Stage 4. -- **Proof on all three flavours:** Control (core supporting), Buffer (light supporting), PreviewDriver (catalog) `///`-enriched incl. per-method descriptions. - -**Stage 3 — "working system" (→ commit):** -- Generate all 132 pages (cross-check header on each); build the 3 summary pages (core-UI, core-supporting [done], light-supporting), same `### `-block → 4-col-table transform every summary page uses. -- **Keep every old `.md`.** No deletions, no retargeting of old pages' links. The new system lands *alongside* the old — a complete, committable baseline. **Commit here.** - -**Stage 4 — optimize (incremental, per module):** -- Sweep the `///` comments module-by-module so each generated page reads as excellent developer docs (consolidate existing `//` into `///`, add per-method/attribute descriptions, fold the old `.md`'s cross-file rationale into the class `///` or the summary page). Cross-check against the still-present old `.md` via the temporary header. -- Apply the two rendering gotchas from the standard on every `///` touched (backtick `<…>`, no mid-sentence `.`). -- **Control-name drift guard.** A summary page's `- name — …` param lines are hand-authored (the controls come from runtime `controls_.add("name", …)` calls no static tool sees — same reason catalog cards hand-author them). They can drift when a control is renamed in the `.h`. Extend `check_specs.py` (which already validates numeric *ranges* between `.h` and catalog docs) to also validate that each summary-page param *name* matches a real `controls_.add("<name>", …)` in the module's source — so a renamed/removed control fails the check. Applies to every summary page with a Parameters column (Core UI + the few supporting modules with controls). - -**Stage 5 — switchover + reconciliation (last, atomic):** -- Once the generated pages are good: **atomically** delete all ~29 old per-module `.md`, retarget every inbound link (links from other deleted `.md` vanish with them; links from permanent docs — architecture.md, surviving summary pages — repoint at the summary page or the generated page), and remove the temporary cross-check header from `gen_api.py`. -- `mkdocs.yml` nav (34 per-module entries): replace the per-module page list with the summary pages + a generated-pages note (moxygen pages are link-reachable, not nav-listed — same policy as history/backlog). -- `check_specs.py`: exemption path already updated to `{domain}/moxygen/` + discovery list (Stage 2). -- **architecture.md line ~112** ("Each MoonModule is documented in `docs/moonmodules/` as it is built") → rewrite to the two-surface model, so the two docs agree (PO-approved to land in this change). -- Clean the stale Phase-4b `api/` naming in `gen_api.py`'s docstring. - -## Files - -- **Edit:** `moondeck/docs/gen_api.py` (domain-nested output + module discovery), `moondeck/docs/mkdocs_hooks.py` (link retarget), `moondeck/check/check_specs.py` (exemption path), `mkdocs.yml` (nav), `.gitignore` (moxygen dirs), `docs/architecture.md` (line ~112), the ~29 `src/**/*.h` (enrich `///`), the 4 catalog summary pages (link retarget). -- **New:** 3 summary pages (`core/ui/`, `core/supporting/`, `light/supporting/`). -- **Delete:** ~29 per-module `.md` (12 core + `ui.md` + 10 light-supporting + 7 catalog-detail — as each is absorbed), `docs/poc/`. - -## Out of scope (named, not silently dropped) - -- **Committing the moxygen output** — stays gitignored; the commit+drift-gate is a later one-line flip if PR-review of generated docs earns it. -- **Test-doc generation via moxygen** — the standard already rules it out (unit tests are macros, scenarios are JSON); `generate_test_docs.py` is untouched. -- **Splitting catalog pages** (`effects_wled.md` / `effects_moonmodules.md`) — future, when a page gets too long. - -## Verification - -- `docs/moonmodules/` contains only summary pages (no per-module `.md`); every module has a `{domain}/moxygen/<Module>.md` at build. -- A summary link lands on the generated page in-site; no broken nav/links (mkdocs build clean). -- Enriched `.h` compile clean (`///` inert); `ctest` + scenarios green; `check_specs.py` green. -- architecture.md and coding-standards.md agree on the doc model (no contradiction). -- Net doc-file count **down** (~29 deleted, 3 created). -- PO reads a generated page (Control) + a summary page and judges them complete. diff --git a/docs/history/plans/Plan-20260703 - S31 RGMII Ethernet.md b/docs/history/plans/Plan-20260703 - S31 RGMII Ethernet (shipped).md similarity index 100% rename from docs/history/plans/Plan-20260703 - S31 RGMII Ethernet.md rename to docs/history/plans/Plan-20260703 - S31 RGMII Ethernet (shipped).md diff --git a/docs/history/plans/Plan-20260703 - WLED audio sync.md b/docs/history/plans/Plan-20260703 - WLED audio sync (shipped).md similarity index 100% rename from docs/history/plans/Plan-20260703 - WLED audio sync.md rename to docs/history/plans/Plan-20260703 - WLED audio sync (shipped).md diff --git a/docs/history/plans/Plan-20260704 - File Manager drag-drop + download + usage bar.md b/docs/history/plans/Plan-20260704 - File Manager drag-drop + download + usage bar (shipped).md similarity index 100% rename from docs/history/plans/Plan-20260704 - File Manager drag-drop + download + usage bar.md rename to docs/history/plans/Plan-20260704 - File Manager drag-drop + download + usage bar (shipped).md diff --git a/docs/history/plans/Plan-20260704 - IrModule brightness.md b/docs/history/plans/Plan-20260704 - IrModule brightness (shipped).md similarity index 100% rename from docs/history/plans/Plan-20260704 - IrModule brightness.md rename to docs/history/plans/Plan-20260704 - IrModule brightness (shipped).md diff --git a/docs/history/plans/Plan-20260704 - Streamed file upload+download (any size).md b/docs/history/plans/Plan-20260704 - Streamed file upload+download (any size) (shipped).md similarity index 100% rename from docs/history/plans/Plan-20260704 - Streamed file upload+download (any size).md rename to docs/history/plans/Plan-20260704 - Streamed file upload+download (any size) (shipped).md diff --git a/docs/history/plans/Plan-20260705 - Homebridge MQTT control.md b/docs/history/plans/Plan-20260705 - Homebridge MQTT control (shipped).md similarity index 100% rename from docs/history/plans/Plan-20260705 - Homebridge MQTT control.md rename to docs/history/plans/Plan-20260705 - Homebridge MQTT control (shipped).md diff --git a/docs/history/plans/Plan-20260705 - Rename scripts to moondeck.md b/docs/history/plans/Plan-20260705 - Rename scripts to moondeck (shipped).md similarity index 100% rename from docs/history/plans/Plan-20260705 - Rename scripts to moondeck.md rename to docs/history/plans/Plan-20260705 - Rename scripts to moondeck (shipped).md diff --git a/docs/index.md b/docs/index.md index 4e6725d3..a5355d5a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,7 +29,7 @@ It runs on ESP32 (the primary target), and also on Teensy, macOS, Windows, Linux Browse the effects, layouts, modifiers, and drivers you compose into a show. - [Effects](moonmodules/light/effects/effects.md) · [Layouts](moonmodules/light/layouts/layouts.md) · [Modifiers](moonmodules/light/modifiers/modifiers.md) + [Effects](moonmodules/light/effects.md) · [Layouts](moonmodules/light/layouts.md) · [Modifiers](moonmodules/light/modifiers.md) - :material-check-decagram: **See what's verified** @@ -41,7 +41,7 @@ It runs on ESP32 (the primary target), and also on Teensy, macOS, Windows, Linux System design, the module model, and the per-module reference. - [Architecture](architecture.md) · [Core modules](moonmodules/core/supporting/supporting.md) · [Light pipeline](moonmodules/light/supporting/supporting.md) + [Architecture](architecture.md) · [Core modules](moonmodules/core/supporting.md) · [Light pipeline](moonmodules/light/supporting.md) </div> diff --git a/docs/moonmodules/core/archive/AudioModule.md b/docs/moonmodules/core/archive/AudioModule.md deleted file mode 100644 index 144b773f..00000000 --- a/docs/moonmodules/core/archive/AudioModule.md +++ /dev/null @@ -1,125 +0,0 @@ -# AudioModule - -Acquires an audio source and publishes an **AudioFrame** — an overall sound **level**, a 16-band frequency **spectrum**, and the **dominant peak**. The frame is available to consumers every render tick, but its analysed values are *recomputed* only when a full sample block has accumulated (a 512-sample block at 22 kHz takes ~23 ms, longer than one tick), so a tick that doesn't complete a block re-publishes the previous `AudioFrame` unchanged rather than re-analysing. It is the producer half of the audio-reactive pipeline; [AudioVolumeEffect](../../light/effects/effects.md) and [AudioSpectrumEffect](../../light/effects/effects.md) are the consumers. - -It is named for what it does, audio acquisition plus analysis, not for one source: today the source is a digital I²S MEMS microphone (the only one wired), and the same analysis pipeline is built to serve other sources (line-in, USB audio) behind the platform read seam as they are added. The candidate source types — I²S with an MCLK for line-in, PDM mics, analog line-in, and I²C-configured codecs — are surveyed in the [Troy (troyhacks)](#prior-art) prior-art notes below. Most of the module is the analysis (DC-blocker, RMS level, windowed FFT, band mapping), which is source-independent. - -A SystemModule **Peripheral**, **added by the user** (not auto-wired on a default flash). It is a microphone peripheral, useful only on a board that actually has an I²S mic, so it follows the same model as the effects: registered in the factory, added through the UI when wanted, not boot-wired. (Auto-wiring it forced an I²S init on every board, which on the classic ESP32 hung `setup()` and boot-looped a mic-less device.) When added, its pins default to **unset (−1)** (the standard [Pin control](Control.md) sentinel, so GPIO 0 stays a usable mic pin) and it stays idle, with a status note, until the user enters the real GPIOs, so adding it never grabs arbitrary pins. The audio effects reach the live frame through the static `AudioModule::latestFrame()`, which returns a permanently-silent frame when no mic exists, so they simply stay dark. - -## Hardware: INMP441-class digital mic - -Built and tested against an **[INMP441](https://invensense.tdk.com/wp-content/uploads/2015/02/INMP441.pdf)** (a self-clocked I²S MEMS microphone): standard/Philips framing, 24-bit data left-justified in a 32-bit slot, mono. Three wires plus power: - -Each pin defaults to −1 (unset); the values below are the bench INMP441 wiring, not code defaults. - -| Control | Bench pin | Role | -|---|---|---| -| `wsPin` | 4 | word-select / LRCLK | -| `sdPin` | 5 | serial data out of the mic | -| `sckPin` | 6 | bit clock | - -The part is self-clocked from the bit clock; there is no master-clock (MCLK) pin. It drives the one slot its L/R select pin chooses (tie L/R to GND for the left slot, VDD for the right); if `level` stays at the floor with sound present, the mic is filling the other slot; the fix is one wire, not firmware. - -## How the AudioFrame is produced - -Each `loop()`: read a block of samples → DC-blocker high-pass → compute the level → window + FFT → map to bands. The high-pass conditions the raw block once, up front, so both the level and the spectrum see the same cleaned signal. - -- **DC-blocker high-pass** (`AudioLevel.h::DcBlocker`, host-tested): a one-pole/one-zero IIR high-pass (`y[n] = x[n] − x[n−1] + R·y[n−1]`, `R = 0.99`, ≈ 40 Hz corner at 22 kHz) applied to the whole block before any analysis. It removes the MEMS mic's large constant DC bias *and* sub-bass rumble below ~40 Hz (handling/wind/structural) that would otherwise leak into the lowest band. Its state carries across blocks (it's a continuous filter, not per-block), and it resets when the channel re-inits. This is distinct from, and runs before, the level path's own block-mean subtraction below. -- **Level** (`AudioLevel.h`, host-tested): subtract the block's DC mean (belt-and-braces after the high-pass), take the RMS, and map it through the log/dB window (`floor` / `gain`). It is the overall loudness, independent of the FFT: the VU value. (It uses a gentler floor than the bands so the meter keeps moving with volume rather than gating hard.) The frame carries this **raw** value as `level` (recomputed each block, snaps to transients — for beat-reactive effects) plus `levelSmoothed`, an exponential moving average of it (lags and rounds off sudden changes — for calm/breathing effects). Consumers pick the one matching their look: NoiseMeter reads raw `level`; the AudioVolume / AudioSpectrum VU meters and FreqMatrix read `levelSmoothed`. (WLED's `volumeRaw` / `volume` pair.) -- **Spectrum** (`AudioBands.h`, host-tested): apply a [Hann window](https://en.wikipedia.org/wiki/Hann_function) (the standard general-purpose FFT window, tapers the block edges so a tone doesn't smear across bins), run the FFT (`platform::audioFft`), then group the magnitude bins into 16 log-spaced bands (a plain geometric / equal-ratio bin split) and pick the loudest bin as the dominant peak (argmax). The peak is held when no real signal is present so it doesn't wander in silence. - -Only the I²S read and the FFT kernel are platform code (`platform_esp32_i2s.cpp`: IDF's [`i2s_std`](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/i2s.html) driver + [esp-dsp](https://github.com/espressif/esp-dsp)'s float `dsps_fft2r_fc32`); everything else is plain domain math that runs in CI on the desktop's reference DFT. - -The DSP choices are the textbook defaults on purpose: a **Hann** window, **RMS** for level, a **geometric** band split, **argmax** for the peak. There is deliberately **no per-frequency correction table**; the INMP441 is flat ±3 dB across the range that matters ([datasheet](https://invensense.tdk.com/wp-content/uploads/2015/02/INMP441.pdf)), so there is no mic-response error to compensate, and a hand-tuned correction curve would add complexity for nothing. The level is overall RMS loudness computed independently of the FFT, not derived from the bands; deriving it from the bands would stop it tracking volume. - -## Controls - -- `wsPin` / `sdPin` / `sckPin`: the three I²S GPIOs (see table above). Changing any re-creates the I²S channel **live** — no reboot ([§ Live reconfiguration](../../../architecture.md#live-reconfiguration-every-change-applies-without-a-reboot)); notable for an audio peripheral, where most firmware (WLED's audioreactive usermod included) bakes the mic pins in and needs a restart to change them. -- `sampleRate`: a dropdown over the standard rates (8000 / 16000 / 22050 / 44100 Hz), default **22050** (~11 kHz Nyquist covers the range that matters for light). Changing it re-creates the channel live. -- `floor`, the noise floor: bands and level below this read as silence, so an ambient room stays dark. Raise it for a noisy room, lower it for a quiet one. Default 100. -- `gain`, sensitivity: higher = more (a narrower dB window, so a given sound fills more of the bar). Default 222. -- `level RMS`: read-only RMS sound level. The display shows the PEAK level over each 1-second window (the live value the LEDs use is recomputed ~43×/s; sampling it once a second would read 0 between beats even while the meter LEDs move). -- `peakHz`: read-only dominant frequency (updates each second). -- `simulate`: a dropdown that replaces the mic signal with a **synthesized `AudioFrame`**, so audio-reactive effects light up on a board with no microphone (a preview/demo device) and so audio behaviour can be exercised deterministically without playing real sound. Options: **off** (mic only); **music (silence)** / **sweep (silence)** — synthesize *only* when the real mic is quiet, so a mic board falls back to the synth between songs; **music (always)** / **sweep (always)** — always synthesize, ignoring the mic. *music* is a plausible song (per-band sine oscillators, a swelling volume, a periodic beat pulse, a drifting peak frequency); *sweep* marches a single lit band bass→treble, a frequency-response test pattern. Default **off**. - -## Cross-domain wiring - -AudioModule produces an `AudioFrame` (`src/core/AudioFrame.h`); the consuming effects reach the live frame through the static **`AudioModule::latestFrame()`**, not a boot-time setter, so an effect added through the UI at any time still finds the one mic, and with no mic it gets a static silent frame. The active module registers itself in `setup()` and clears that pointer in `teardown()`, so adding or removing the mic (or an effect) in any order always leaves a coherent answer. - -The first ~250 ms after the I²S clock starts are power-on settling garbage; the read is non-blocking (the hot-path rule), so those samples flow through the first few `loop()` reads and the level/bands self-correct within that quarter-second; the frame stays valid (zeroed) until then. - -## Prior art - -Audio-reactive lighting is a long-standing idea in the LED-controller world (WLED-MM and MoonLight are the closest lineage). This is projectMM's own implementation, designed from the INMP441 datasheet and standard DSP rather than traced from any one project's code or band tables; the rationale for the specific DSP choices is in [How the AudioFrame is produced](#how-the-audioframe-is-produced) above. The history of *what was tried and removed* (notably a self-calibrating auto-gain / noise-floor conditioner, deferred as its own increment) lives in [decisions.md](../../../history/decisions.md). - -**Frank (softhack007).** [Frank](https://github.com/softhack007) is the main author of the WLED-MM audioreactive usermod, the most-used open-source audio-reactive LED implementation, and a direct ancestor of the ideas this module learns from. projectMM's product owner worked alongside Frank for years on WLED-SR / WLED-MM before starting MoonLight and then projectMM, so the collaboration goes back a long way. We don't trace his code (per the [*Industry standards, our own code*](../../../../CLAUDE.md#principles) principle), but we study his thinking with real respect and credit it by name; the *Adaptive noise gate* section below is the first worked example: his concept, our analysis, written fresh against our own architecture. - -**Troy (troyhacks).** [Troy](https://github.com/troyhacks) is, like Frank, part of the MoonModules team. He keeps his own fork of WLED-MM at [troyhacks/WLED](https://github.com/troyhacks/WLED), where (on the `P4_experimental` / `Pure_IDFv5_Port` branches) he reworked the audioreactive usermod's DSP to run on Espressif's **esp-dsp** library — a combination he reports as "stupid fast compared to ArduinoFFT … stupid fast even without on-chip acceleration," with very low latency on the S3 and P4. The relevant file is [`usermods/audioreactive/audio_reactive.h`](https://github.com/troyhacks/WLED/blob/17cee3a63f775a97c80c9b433995acfc6e7413e2/usermods/audioreactive/audio_reactive.h) (guarded by `UM_AUDIOREACTIVE_USE_ESPDSP_FFT`). His contribution has two parts, and our assessment of each follows. - -*The esp-dsp FFT.* Troy uses esp-dsp's **radix-4** real FFT (`dsps_fft4r_fc32` → `dsps_bit_rev4r_fc32` → `dsps_cplx2real_fc32`) with a Blackman-Harris window. This is the right family, and it validates the path projectMM is already on: **we use esp-dsp too** — `dsps_fft2r_fc32`, the **radix-2** float real FFT — in [platform_esp32_i2s.cpp](../../../../src/platform/esp32/platform_esp32_i2s.cpp) (see [How the AudioFrame is produced](#how-the-audioframe-is-produced)). So Troy's "winning combination of speed and acceleration" and ours are the same library; the one open optimisation is **radix-4 vs radix-2**. For a power-of-two real FFT, radix-4 does fewer butterfly stages (log₄ N vs log₂ N) and is the textbook faster choice on a float FPU — a measured, low-risk follow-up for this module if FFT time ever shows up in the tick budget (today it doesn't: the float FFT on the S3/P4 FPU is well inside one tick). Worth noting two adjacent, *not yet adopted* options so the trade-offs are on record: (a) esp-dsp also exposes an **int16 / fixed-point** path that uses the **built-in FFT instructions on the S3 and P4** — that is the "even faster if the hardware has the functions baked in" Troy refers to; we deliberately run **float** today because our targets have an FPU and float keeps the band math simple (the *Industry standards, our own code* call), but the hardware-accelerated int16 path is the lever for low-power FPU-less chips (C3 / S2); and (b) Espressif's standalone **[`dl_fft`](https://components.espressif.com/components/espressif/dl_fft)** component does *only* FFT (float or hardware-accelerated int16) without esp-dsp's shared-global twiddle tables — the "new FFT lib that doesn't drag in all of ESP-DSP" — which **we do not use** (we take the whole esp-dsp dependency because we also want its DSP primitives), but it is the right pick if a future build wants the FFT without the rest of esp-dsp. - -*The biquad pre-filters.* Before the FFT, Troy runs the time-domain samples through **biquad** high-pass, low-pass, and a peaking ("notch to boost the mids") filter using esp-dsp's optimised `dsps_biquad_f32` (not hand-rolled), with 5-coefficient direct-form sections designed in [EarLevel Engineering's Biquad Calculator v2](https://www.earlevel.com/main/2013/10/13/biquad-calculator-v2/) (the "web-based visual biquad tool that spits out the 5 values"); he also bundled an offline copy into the WLED web UI as [`biquad.htm`](https://github.com/troyhacks/WLED/blob/17cee3a63f775a97c80c9b433995acfc6e7413e2/wled00/data/biquad.htm). This is squarely industry-standard — a [biquad / second-order section](https://en.wikipedia.org/wiki/Digital_biquad_filter) is *the* canonical building block for audio EQ and pre-emphasis, and the [Audio EQ Cookbook](https://www.w3.org/TR/audio-eq-cookbook/) coefficients EarLevel emits are the recognised reference. Our current pipeline does one fixed [DC-blocker high-pass](#how-the-audioframe-is-produced) (~40 Hz) to strip the offset before analysis; Troy's contribution shows the natural next step — making that filter stage a *configurable* biquad chain (HP to kill rumble, LP to tame aliasing, optional peaking to lift the mids the FFT under-reports). **Priority/assessment:** the FFT is already shared ground (radix-4 is a measure-then-maybe tune-up, not a gap); the **biquad pre-filter chain is the higher-value idea to adopt**, because it improves spectral accuracy (Troy: "the HP and LP filters improved the FFT output accuracy") with off-the-shelf primitives and a known design tool, and it composes cleanly with the forward-looking *Adaptive noise gate* below — a learned gate keyed to a cleanly-filtered signal is better than one keyed to a raw one. Both remain analysis here, written fresh against our architecture, not traced from Troy's code. - -*Fixed-point, and adjacent WLED work.* Frank's note that "esp-dsp is the way to go for AR 2.0" while "Damian is tinkering with fixed-point … the low-power C3 and S2 boards" maps onto a real WLED feature: **Damian Schneider — [DedeHai](https://github.com/DedeHai)** — is the same person as "Dedehai" (one contributor, not two), a WLED core developer, and WLED's audioreactive usermod already carries an **integer / fixed-point FFT** path ([`UM_AUDIOREACTIVE_USE_INTEGER_FFT`](https://github.com/wled/WLED/blob/main/usermods/audioreactive/audio_reactive.cpp), ~1.5 ms on a C3, "over 10× faster than ArduinoFFT" on FPU-less chips). Troy's and Frank's read is that with esp-dsp's FFT + biquads, **fixed-point is not necessary on FPU chips** (S3 / P4) — which is exactly projectMM's position: float on FPU targets, and the int16 / `dl_fft` hardware path noted above is the lever reserved for the low-power chips if we ever target them. Will's note that "Dedehai has already built something similar" in WLED is also accurate — DedeHai's current audio experiment is a [PoC MSGEQ7-based AudioReactive](https://github.com/wled/WLED/pulls?q=is%3Apr+author%3ADedeHai) (offloading the spectrum to a dedicated hardware analyser chip rather than running FFT on the MCU at all) — a different point in the same design space, recorded here for completeness. - -*Source types beyond the I²S MEMS mic.* Troy's read on the current single-source spec: it's a good base, and the natural extension is to **broaden the source seam** rather than the analysis. Four source types are worth supporting, in roughly increasing hardware complexity: - -- **I²S with an MCLK, for line-in.** The [INMP441 is self-clocked and has no MCLK](#hardware-inmp441-class-digital-mic); a line-in codec generally needs a master clock the ESP32 drives. Adding an optional `mclkPin` to the I²S read seam covers the line-in case without changing the analysis. -- **PDM mics**, for boards that ship with one. A different I²S sub-mode (the IDF [`i2s_pdm`](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/i2s.html) driver) — another variant behind the same platform read, not a new pipeline. -- **Analog line-in.** Long held to "only the original ESP32," but the field has moved: **[DedeHai](https://github.com/DedeHai) got analog input working on the S3**, and **Troy got it working in his ParrotRadio project**. Troy flags a *testing-confidence* nuance worth recording: he considers his own ParrotRadio analog path **better exercised** — he was actually recording and playing audio back through it and chasing down real issues — whereas an unlistened-to analog path elsewhere may not be as accurate as it looks, "if nobody's ever listened to it." So if projectMM adopts analog line-in, **validate by listening**, not just by watching the level meter move. -- **I²C-configured codecs** (e.g. the **ES8311**): the right move is explicitly **not** to hand-roll each codec's register config (which is what Troy did in WLED). Espressif ships an **[`esp_codec_dev`](https://components.espressif.com/components/espressif/esp_codec_dev) "codecs" component** for IDF that already carries the option tables for many codecs; pulling it in would support "a bunch more codecs for free" and let users configure them for their own hardware. If something Troy hand-rolled turns out to be missing from the component, the codec class is extensible — but he doubts anything is. This is the [*Industry standards, our own code*](../../../../CLAUDE.md#principles) call applied to codec bring-up: take Espressif's component rather than a bespoke per-codec config. - -Troy also has **DSP boards on his desk** — essentially I²S front-ends "waaaaay beyond the regular codecs" — a class of source recorded here so the line-in / codec work leaves room for it rather than only the simple cases. All of the above is **source-seam** work: it widens what feeds the pipeline, leaving the DC-blocker / RMS / FFT / band analysis untouched. Tracked under [backlog](../../../backlog/README.md). - -## Adaptive noise gate: forward-looking - -> **Present-tense exception (justified).** Module specs are otherwise present-tense ([CLAUDE.md](../../../../CLAUDE.md)); this section is forward-looking by deliberate choice, so the design analysis stays with the module it extends. It describes a concept and our judgement of it, not shipped behaviour. The shipped audio path is everything above. - -This concept comes from softhack007 (see [Prior art](#prior-art)), who granted permission to analyse it here. The proposal: replace the borrowed `squelch`/`noiseFloor` knob, described as "a WLED-SR workaround, not a real gate," with a proper adaptive noise gate. The rest of this section is our own assessment. - -### The concept - -- A **standard [noise gate](https://en.wikipedia.org/wiki/Noise_gate)**: below a threshold the signal is silenced (gate closed), above it the signal passes (gate open). -- **Asymmetric, bang-bang timing:** open **fast**, close **slow**. A bang-bang (hysteresis) controller avoids chatter at the threshold. -- A **new "detect silence" function** drives the gate. This is the explicitly *unfinished* part of the idea. -- **Leave the GEQ / FFT channels untouched.** The gate acts on the time-domain signal, not the bands. (A *per-band* noise threshold is noted as possibly also worth having.) -- The closing pre-condition should be **relative, not an absolute sample count**: a "percentage of average signal," not a fixed number. -- Optionally feed the gate **compressed samples** (sqrt or log) so the threshold behaves perceptually rather than linearly. - -Five design constraints come with it, and they are the load-bearing part: (1) samples are **signed**, of **arbitrary magnitude**, and scaling to an effect range is AGC's job, not the gate's; (2) **every `abs()` must be justified** (a rectify discards sign/phase); (3) **prefer relative factors to absolute thresholds**, the one allowed absolute being that changes **< 2** counts are sampling noise; (4) **smooth before thresholding**; (5) **every filter adds delay, and total audio delay must stay < 30 ms.** - -### Is this a good idea? Our verdict - -**Yes, directionally, and it is squarely industry-standard.** A hysteresis noise gate with a fast-attack/slow-release envelope is the textbook design for exactly this problem (it is how studio gates, two-way-radio squelch, and voice-activity detectors all work), so adopting it moves us *toward* the recognisable solution and *away* from the borrowed `squelch` constant, which is the right direction under the [*Industry standards, our own code*](../../../../CLAUDE.md#principles) principle. The relative-threshold insight (constraint 3) is the genuinely valuable core: a gate keyed to a *learned* floor self-calibrates to whatever mic or line source is connected, where an absolute squelch only ever suits one setup. So the idea is sound and worth doing. - -**Two cautions keep it from being a drop-in.** First, **timing is tight and must be proven, not assumed.** A 512-sample block at 22050 Hz is already ~23 ms of buffering before analysis begins; that leaves under ~7 ms of the 30 ms budget for everything the gate adds. The block size, not the gate, is the dominant cost, so any smoothing the gate introduces must be cheap (one-pole) and the *open* path especially must not lengthen it. This is measurable on hardware and a hard gate on the design. Second, it overlaps work we have already scoped (the per-band floor, below), so the risk is building a parallel mechanism instead of one coherent one. Both push the same way: **decompose and adopt in steps, do not overhaul.** - -### Does our per-band floor already cover part of this? - -Partly, and that overlap is the key to sequencing. The backlogged [per-band noise-floor](../../../backlog/README.md) learns each band's idle baseline and subtracts it, so a *steady single-frequency* tone (our bench's ~258 Hz mains hum) gates to dark while the other bands stay live. The proposed time-domain gate answers a *different* question, "is there any sound at all," across the whole signal. They are complementary halves, not competitors: the per-band floor is the **frequency-domain** noise floor, the gate is the **time-domain** one. The per-band floor is also the smaller, already-planned step, so it is the natural first increment, and it is genuinely "part of this idea," not a thing the gate replaces. - -### How to decompose it: cherry-pick, step by step - -The whole proposal is more than one increment. Taken apart, most of its value lands early and cheaply, and the riskier parts can wait or be dropped: - -1. **Per-band noise floor (already backlogged).** Ship this first. It is the frequency-domain half, the smallest change, and it kills the concrete hum we actually see. Independent of everything below. -2. **Relative thresholds, reusing the RMS we already compute.** The single most valuable idea here is "threshold against a learned floor, not an absolute number." `computeLevel` already produces a per-block **RMS**, which *is* an envelope estimate (and RMS is the one justified `abs()` under constraint 2: it is the energy measure, not a naive rectify). So a learned-floor follower over that RMS, with open/close as **factors** of it, is a small, host-testable addition that needs *no new DSP stage* and *no extra delay* (the RMS is on the critical path already). This is the cherry to pick. -3. **Hysteresis + asymmetric timing.** The fast-open/slow-close behaviour falls out of two time-constants on that follower plus a close-hold, not a separate state machine. Cheap to add once step 2 exists; this is where the < 30 ms budget gets measured for real. -4. **Optional, defer until proven needed:** log/dB-domain thresholds (our `magToByte` already does perceptual compression downstream, so the detector can stay linear at first and move to dB only if the linear factors prove twitchy), and a true soft gate (0..1 gain vs a hard 0/1). - -Each step is its own commit, host-tested red-first, and leaves the system working; none requires touching `AudioBands.h` or the effect consumers. Steps 1–2 deliver most of the benefit (a self-calibrating floor in both domains) with almost no timing cost; 3–4 are polish to layer on only if the bench says they earn their place. - -**What it eventually retires:** the `floor` knob's role as a hard squelch. `floor` would become the *display* noise-floor only (the dB-window bottom in `magToByte`), while the learned gate decides "is there sound." That is a clean subtraction, but it is the *end* of the path, not the first step. Tracked under [backlog](../../../backlog/README.md). - -## Tests - -Full case lists are in the generated inventories — [unit tests § AudioModule](../../../tests/unit-tests.md#audiomodule) and [scenario tests § AudioModule](../../../tests/scenario-tests.md#audiomodule) (both regenerated from the test files, so they never drift). What each layer covers: - -- **Level + Spectrum (CI, host):** the signal math runs end-to-end on synthesized blocks through the desktop reference DFT — silence/DC read 0, a louder sine reads higher, the `floor`/`gain` knobs gate and scale, a tone lands in the right band and `peakHz` tracks it, energy concentrates rather than smears, and degenerate input never crashes. -- **Module lifecycle (CI, host):** the part the classic-ESP32 boot-loop showed was risky — a fresh module is idle with pins unset (never inits a mic by merely existing), setup/teardown is repeatable with no residue, `teardown()` clears the active mic so `latestFrame()` falls back to silence (no dangling pointer), and last-setup-wins under any add/remove order (the robustness rule). -- **Mutation scenario (CI, host):** add / configure / remove the mic and a consumer effect while the pipeline renders — the hard case is removing the producer while a consumer is still live, which must keep rendering on silent audio. The boot-loop robustness, proven end-to-end through the Scheduler. -- **Hardware:** on the S3 with an INMP441, `level` fluctuates with how loud the room is, the spectrum bars track played tones, `peakHz` follows the dominant frequency, and raising `floor` keeps an ambient room dark. - -## Source - -[AudioModule.h](../../../../src/core/AudioModule.h) · [AudioFrame.h](../../../../src/core/AudioFrame.h) · [AudioLevel.h](../../../../src/core/AudioLevel.h) · [AudioBands.h](../../../../src/core/AudioBands.h) · [platform_esp32_i2s.cpp](../../../../src/platform/esp32/platform_esp32_i2s.cpp) diff --git a/docs/moonmodules/core/archive/Control.md b/docs/moonmodules/core/archive/Control.md deleted file mode 100644 index 7f49b31f..00000000 --- a/docs/moonmodules/core/archive/Control.md +++ /dev/null @@ -1,58 +0,0 @@ -# Control - -A named, typed value a MoonModule exposes to the UI. Kept as lightweight as possible. - -## Design - -Controls bind to a class variable **by reference** — the descriptor stores a pointer, hot-path code reads the variable directly (zero overhead, no getter/setter). The value lives in the class variable (1–4 bytes); the descriptor is just the metadata UI rendering and persistence need. - -## Types - -Each `controls_.addX(name, var, …)` call (signatures in `Control.h`) binds one of: - -| Type | Storage | UI | DMX | -|------|---------|-----|-----| -| Uint8 | 1 byte, min/max | Slider (0–255) | Yes — preferred default | -| Uint16 | 2 bytes | Number input | Yes (universe, port) | -| Int16 | 2 bytes, min/max | Slider (bounded; unbounded → ±percentage slider) | Yes | -| Pin | 1 byte (`int8_t`), −1 = unused | Number input | No | -| Bool | 1 byte | Toggle | Yes (0/1) | -| Text | `char[N]` | Text input | No | -| Password | `char[N]` | Masked input, hold-to-peek | No | -| ReadOnly | `char[N]` | Display text | No | -| ReadOnlyInt | 1 byte + unit string in `aux` | Display `<value> <unit>` | No | -| Select | uint8_t index + options in `aux` | Dropdown | Yes (mode) | -| Progress | uint32_t value + total | Progress bar | No | -| IPv4 | uint8_t[4] | Dotted-quad text input | No | - -Notes on the non-obvious ones (the rest are self-describing): - -- **Password** serializes XOR-obfuscated + base64 over `/api/state`, not plaintext — a first line of defence, trivially reversible by design (the XOR key is shared with `app.js`), not encryption. -- **Int16** is for coordinate-style values where negatives are legal. Default bounds are the full int16 range; pass explicit bounds for a tighter one. The UI renders it as a slider (an unbounded int16 falls back to a ±percentage slider). -- **Pin** is a GPIO number — `int8_t` (one byte; a GPIO never exceeds ~54), `−1` = unused/default. Distinct from Int16 so the UI renders a plain **number input** (a GPIO has no meaningful range to drag) and to keep the byte. `min`/`max` are the valid-GPIO span, used only as a server-side write-clamp. [NetworkModule](NetworkModule.md)'s eth pin controls are the first users; LED-driver pins follow. -- **ReadOnlyInt** stores 1 byte + a unit suffix instead of a ~10-byte string — see [coding-standards § Prefer integers](../../../coding-standards.md#prefer-integers-store-values-in-their-native-shape). [NetworkModule](NetworkModule.md)'s `rssi` (`-58 dBm`) and `txPower` (`19 dBm`) are the first users. -- **IPv4** stores 4 bytes but converts to/from the dotted-quad string at the JSON boundary (`parseDottedQuad`/`formatDottedQuad` in `Control.h`, used by API, persistence, and scenario set-control). Used for [NetworkModule](NetworkModule.md)'s static-IP fields. - -No RGB color-picker type — effects use a palette index (uint8_t) instead. `float` and `Coord3D` exist but are used minimally; prefer uint8_t. - -## Memory footprint - -Target: under 16 bytes per descriptor (variable pointer + flash name pointer + type enum + type-dependent min/max). Descriptors live in a fixed-capacity per-module array — no per-control heap allocation. A module that overflows the default capacity is probably too complex. - -## Persistence and dynamic rebuild - -Control values persist via [FilesystemModule](FilesystemModule.md), which overlays loaded values through each control's pointer during `onBuildControls()`. Calling `onBuildControls()` again at runtime (e.g. when a Select changes) clears and rebuilds the set, so only controls relevant to the current mode are shown — this is how conditional `hidden` flags re-evaluate. - -## Tests - -[Unit tests: MoonModule](../../../tests/unit-tests.md#moonmodule) — control binding by reference, pointer read/write, clear and rebuild. - -## Prior art - -### MoonLight — addControl ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonBase/Nodes.h#L80)) - -Binds via `reinterpret_cast<uintptr_t>(&variable)`; UI types "slider"/"select"/"toggle"/"text"/"display". - -## Source - -[Control.cpp](../../../../src/core/Control.cpp) · [Control.h](../../../../src/core/Control.h) diff --git a/docs/moonmodules/core/archive/DevicesModule.md b/docs/moonmodules/core/archive/DevicesModule.md deleted file mode 100644 index cca92a41..00000000 --- a/docs/moonmodules/core/archive/DevicesModule.md +++ /dev/null @@ -1,82 +0,0 @@ -# DevicesModule - -![DevicesModule controls](../../../assets/core/Devices%20module.png) - -A **core**, domain-neutral module that discovers other devices on the LAN, identifies what each is, and presents them as a browsable list. It focuses on *all* devices on the network (including this one, marked as self), not on the host's own state — so its card looks the same on every projectMM instance, ESP32 or PC. Light-domain modules consume the device list; the discovery machinery itself stays domain-neutral. - -Submodule of [NetworkModule](NetworkModule.md) — discovery depends on the network being up, the same placement reasoning as [ImprovProvisioningModule](ImprovProvisioningModule.md). Wired by code in `main.cpp` (`networkModule->addChild(devicesModule)`), marked `markWiredByCode()` so persistence preserves it. - -## Controls - -- `devices` — a [List control](Control.md) whose rows are the discovered devices; each row expands to a detail panel. This module is the list's `ListSource`, walking its own `devices_` array (no copy, no allocation). Read-only from the browser (discovery output flows device → `/api/state` → browser), but **persistable** (see Persistence). - -Discovery state ("idle", "N devices", "N devices (cached)") is reported through the standard [MoonModule](MoonModule.md) `setStatus()` channel (rendered generically as the card's status line), not as a separate control. There is no scan button — devices announce themselves; nothing is polled. - -## Discovery (UDP presence, passive) - -Discovery is **passive UDP**: each device **broadcasts** a small presence packet on a well-known port, and this module **listens** (a bound `UdpSocket`, drained non-blocking each tick). No subnet sweep, no per-host probe, **no mDNS query** — a device appears when its broadcast arrives and ages out when it stops. - -- **Broadcast.** Every ~10 s (`kBroadcastEverySec`) this module broadcasts a **44-byte WLED-compatible presence packet** (see [`WledPacket`](../../../../src/core/WledPacket.h)) to `255.255.255.255:65506`: `token=255, id=1`, our IP, deviceName, board-type byte — plus a projectMM marker stamped into the version field (a region no WLED validator reads). So a peer projectMM device recognises us, **and** a real WLED / WLED app browsing 65506 lists us too. Discovery-only: a WLED that receives it shows us in its instances list, it does **not** sync to it (sync/control is a separate WLED protocol on a port WLED never shares). -- **Listen.** Each `loop1s` tick drains the bound listener with non-blocking `recvFrom` (bounded per tick) and classifies each datagram through the plugins. Never blocks the tick — the hot-path-safe replacement for the former mDNS query, which destabilised our own advertise (a PTR query for a service we also host exhausts the IDF mDNS pool — see the [discovery-transport lesson](../../../history/decisions.md)). - -**mDNS is advertise-only.** `mdnsInit` still announces `_http._tcp`+`mm=1` and `_wled._tcp`+`mac=` so the **native WLED app + Home Assistant discover us** (they only browse mDNS — UDP can't replace that). But this module never *queries* mDNS; all discovery is UDP. - -### Plugins (the interop seam) - -Foreign ecosystems hook in as **plugins**, not hardcoded branches — the adapter pattern (cf. `ListSource`, `ModuleFactory`). A [`DevicePlugin`](../../../../src/core/DevicePlugin.h) declares the UDP port it listens on (`discoveryPort()`) and turns a received datagram into a `Device` kind (`classifyPacket`): - -| Plugin | Claims (on UDP 65506) | Classifies as | -|---|---|---| -| `MmPlugin` | a valid WLED packet **with** the projectMM marker | projectMM | -| `WledPlugin` | a valid WLED packet **without** the marker | WLED | - -`MmPlugin` is offered each packet first, so a projectMM peer (which broadcasts a marked, WLED-valid packet) is typed projectMM and not double-claimed as WLED. A new system is **one new plugin file** listed in the module — no core edit. The seam keeps `DiscoveredDevice` plain so a future hub plugin (Hue) extends it without reshaping the flat case; the (reserved) `command()` half translates a generic command into a system's protocol when a control consumer exists. *Concrete first, abstract later.* - -The plugin classification is pure and host-unit-tested (`unit_DeviceIdentify.cpp` feeds synthetic packets, incl. short/garbage → declined), with no network. The full pipeline is tested via `injectPacketForTest` (`unit_DevicesModule_discovery.cpp`) — and because `UdpSocket` works on desktop, the discovery path is host-testable with real datagrams, not just stubs. - -### Out-of-band devices (Hue bridge) - -A device not discovered by UDP presence — a Philips Hue bridge, found over HTTP by a [HueDriver](../../light/drivers/HueDriver.md) — registers itself through `upsertHueBridge(ip, name, colourCount)`, reached via `active()` (the boot-instance static accessor, the `AudioModule::latestFrame()` seam shape). The bridge then lists like any device, with a `colour` field (its colour-light count, for sizing a layout). This keeps the module domain-neutral: the Hue HTTP/pairing lives entirely in the light-domain driver; the core only stores the resulting row. (`unit_DevicesModule_hue.cpp` pins the row + its persistence round trip.) - -### Age-out - -Each sighting stamps the device's `lastSeenMs`; `ageOut()` runs every tick. A live-confirmed device is kept for `kStaleMs` (**24 h**) after its last presence packet, so the list is a durable "devices I've seen" history; a **cached** row (restored from persistence, not yet re-heard this session) gets only a short `kCachedGraceMs` (**60 s**) probation, so a long-gone persisted device can't survive forever across reboots — a live packet promotes it to the 24 h window. A **timestamp**, not a counter. The self row never ages out (it tracks the current local IP). Storage is a fixed `devices_[kMaxDevices]` array — bounded, no heap. - -## Interop — projectMM shows up in WLED - -Because the presence broadcast and the mDNS advertise are WLED-shaped, a projectMM device appears in the WLED ecosystem two independent ways, with no projectMM software on the other side: - -**In WLED's own "Sync interfaces" instances list** — a real WLED lists every projectMM board it heard on UDP 65506. (The `undefined` columns are WLED-sync fields projectMM doesn't fill — the presence packet carries identity, not the full WLED sync state; listing is what we're after.) - -![projectMM devices in WLED's instances list](../../../assets/core/Wled%20discovers%20projectMM.png) - -**In the native WLED app** (iOS / Android) — discovered via the mDNS `_wled._tcp` advertise, validated via the `/json/info` shim, with live colour + a working brightness slider over the `/ws` WebSocket. See [HttpServerModule § WLED-compatibility shim](HttpServerModule.md#wled-compatibility-shim) for the wire contract (reverse-engineered from the [WLED-Android](https://github.com/Moustachauve/WLED-Android) client). - -![projectMM devices in the native WLED app](../../../assets/core/WLED%20Native%20discovers%20projectMM.jpeg) - -## Transport boundary (discovery vs commands) - -Discovery is UDP presence (above) — lossy-OK, never device-to-device *commands*. Those split by need: must-arrive config (set brightness, presets, OTA) rides **REST** (`/api/control`, TCP-guaranteed); latency-critical lossy-OK traffic (time sync, live pixels) rides its own **UDP** stream (NetworkSend/Receive). This module does *discovery*; consumers reach a found device over the right transport for the job. - -## Persistence (instant boot list) - -The discovered list survives reboot: the `devices` [List control](Control.md) is persistable, so a change marks the module dirty and FilesystemModule saves the list as a JSON array; on boot the persistence overlay restores it (via `ListSource::restoreList`, which uses the recursive [JsonUtil](Control.md) reader's `forEachListElement`) *before* the first announcement arrives. So the UI shows the **last-known devices immediately** ("N devices (cached)") rather than waiting for the first re-announcement. The self entry is not restored from the cache (its IP can change); `upsertSelf` re-adds it live with the current address. The restore tolerates an old persisted file carrying extra keys (e.g. the former `via`/`speaks`) — the keyed reader ignores them. - -## Self - -This device always appears in the list (`upsertSelf`, marked `self:true`), so the card shows the whole network including the host. The UI marks the self row distinctly (an accent edge). The self entry is identified by comparing the announcing IP to the local IP. - -## Wire shape - -The `devices` List serializes (via [Control](Control.md)'s `ControlType::List`) as a `value` array of row summaries — `{"name","ip","type",["self"]}` — with a parallel `detail` array carrying `url` and `ageSec` (seconds since last heard, computed device-side as `now − lastSeenMs`; omitted on the self row, which is always current). A device restored from persistence but **not yet re-heard live** this session carries `cached:true` instead of `ageSec` — the UI shows "last seen: cached" until an announcement re-confirms it, at which point `cached` clears and a real `ageSec` appears. The UI renders `ageSec` as a relative "last seen 2m ago". - -## Prior art - -- **mDNS-SD / DNS-SD (Bonjour, Avahi)** — the industry-standard service-discovery pattern this module uses: announce a service, browse for it. WLED, ESPHome, Home Assistant, Hue all speak it. -- **WLED** — the `_wled._tcp` service it advertises (and that the native WLED iOS/Android/Desktop apps browse) is the interop target the `WledPlugin` + the `_wled._tcp` advertise serve. -- **MoonLight** ([`ModuleDevices.h`](https://github.com/ewowi/MoonLight/blob/main/src/MoonBase/Modules/ModuleDevices.h)) uses a UDP presence broadcast for device discovery; DevicesModule carries that idea forward — the 44-byte WLED-compatible packet on UDP 65506 (see [`WledPacket`](../../../../src/core/WledPacket.h)), written fresh against our architecture. mDNS stays advertise-only, for the foreign apps that discover *us* over it (the WLED native app, Home Assistant). -- The web installer's `web-installer/devices.js` "Your devices" list is the prior art for the device record shape (name / url / type). - -## Source - -[DevicesModule.h](../../../../src/core/DevicesModule.h) · [DevicePlugin.h](../../../../src/core/DevicePlugin.h) diff --git a/docs/moonmodules/core/archive/FilesystemModule.md b/docs/moonmodules/core/archive/FilesystemModule.md deleted file mode 100644 index 3e740b86..00000000 --- a/docs/moonmodules/core/archive/FilesystemModule.md +++ /dev/null @@ -1,88 +0,0 @@ -# FilesystemModule - -![FilesystemModule controls](../../../assets/core/FilesystemModule.png) - -Persists control values to flash so settings survive a reboot. Always loaded, runs first in the scheduler so its load hook fires before any other module's `setup()`. - -## Storage layout - -One flat JSON file per top-level module under `/.config/`: - -```text -/.config/ - SystemModule.json → {"deviceName":"MM-TEST","enabled":true} - NetworkModule.json → {"ssid":"home","password":"...","addressing":1, - "mDNS":true,"ip":"192.168.1.55","gateway":"", - "subnet":"255.255.255.0","dns":"","enabled":true} - Layer.json → {"channelsPerLight":3,"enabled":true, - "0.type":"NoiseEffect","0.scale":12,"0.bpm":60, - "0.enabled":true,...} - Drivers.json → {"enabled":true, - "0.type":"NetworkSendDriver","0.ip":"192.168.1.70", - "0.fps":50,"0.enabled":true,...} -``` - -Filename comes from `MoonModule::typeName()`. Child modules are encoded **positionally** with a `<index>.` key prefix — no nested objects, no arrays. The `type` field per child drives **structural reconciliation** at load time: when the JSON describes a child type at position N that differs from the live tree's child at N (built by `main.cpp`), the loader factory-creates the JSON type, calls its `onBuildControls()`, and swaps it into place. Children present in the live tree but missing from the JSON are torn down and deleted; children in the JSON beyond the live tree's end are appended. Phases 3+4 (`setup`, `onBuildState`) cascade into the reconciled tree, so newly-created children are fully initialized like any other. - -`ReadOnly` and `Progress` controls are never persisted — they are derived values, not state. - -## Lifecycle - -`Scheduler::setup()` runs in four phases: - -```text -phase 1 onBuildControls() every module binds its full control set (incl. hidden) -phase 2 loadAllHook() FilesystemModule reads files, overlays bound variables -phase 2b rebuildControls() re-runs onBuildControls so conditional hidden flags see - the persisted values (e.g. NetworkModule's static-IP - fields become visible after a persisted addressing=1) -phase 3 setup() modules' own init runs with persisted values in members -phase 4 onBuildState() buffers sized to final values -``` - -The Scheduler exposes `setLoadAllHook(LoadAllFn fn)` as a function pointer so it stays independent of FilesystemModule's type (no circular include). FilesystemModule wires the hook from `setScheduler()`. - -## Save trigger - -HttpServerModule calls `target->markDirty()` and `FilesystemModule::noteDirty()` on every successful mutation: control changes, **and tree-shape changes** (add / delete / move a module — the parent is marked dirty so its file is rewritten with the new child set). `noteDirty()` stamps `lastDirtyMs_` and sets `dirtyPending_`. In `loop1s()`, FilesystemModule waits `DEBOUNCE_MS` (2000ms) after the last dirty mark, then walks the module tree; any subtree with a dirty descendant is serialized to a flat JSON blob and written atomically (write to `.tmp` then rename). - -A subtree's dirty flag is cleared only after its write succeeds; a failed write leaves it set so `loop1s()` retries. Losing power before the debounce expires loses the in-flight change — the cost of debouncing for fewer flash writes. `FilesystemModule::flushPending()` forces all dirty subtrees through synchronously; `POST /api/reboot` calls it so an add-then-reboot doesn't lose the change. - -The `lastSaved` read-only control shows how long ago the last write happened (`"never"`, `"5s ago"`, `"3m ago"`), refreshed each `loop1s()`. - -The `filesystem` progress control shows the config-partition usage (bytes used / total), refreshed each `loop1s()` from `platform::filesystemUsed()` / `filesystemTotal()`. It is bound only when the platform reports a real partition (a chip without a data partition, or desktop, reports 0 and the bar is omitted). This bar lives here, on the module that owns the filesystem — not on SystemModule. - -## Conditional visibility (`hidden` flag) - -Modules with conditional controls (e.g. NetworkModule's static-IP fields under `addressing=Static`) bind their full control set unconditionally and toggle a `hidden` flag per descriptor: - -```cpp -controls_.addText("ip", staticIp_, sizeof(staticIp_)); -controls_.setHidden(controls_.count() - 1, addressing_ != 1); -``` - -This means the persistence layer can find and overlay `ip` regardless of the live conditional state, while the UI honors the hidden flag (`if (ctrl.hidden) continue` in `renderCards`). When a Select changes at runtime, HttpServerModule calls `rebuildControls()` to re-evaluate the flags. - -## Platform layer - -Filesystem access goes through `platform::fs*` (mount, mkdir, read, atomic write-then-rename, used/total). ESP32 uses LittleFS (`joltwallet/esp_littlefs`) on a dedicated partition; desktop uses `std::filesystem` rooted at `build/` (overridable via `fsSetRoot` for test isolation) so config doesn't clutter the repo root. Save/load shares one 2 KB buffer (`MAX_FILE_BYTES`); a subtree that serializes larger than that fails the write. - -## Tests - -- Unit test (`test_filesystem_persistence.cpp`): - - **Value round-trip**: set `deviceName` → save → fresh `Scheduler` + modules → load → assert. Uses `platform::fsSetRoot()` for test isolation. Wall time ~2.3s (the debounce window dominates). - - **Structural reconciliation**: hand-write a `Layer.json` with one child (RainbowEffect). Build a live tree with two children (NoiseEffect + MultiplyModifier). After load, assert the tree reconciled — RainbowEffect at position 0, the modifier trimmed. - -## First boot - -No files exist → load is a no-op. Modules run with their default member-initialized values. After the first UI change, FilesystemModule debounces 2s and creates the file. Subsequent boots overlay the persisted values. - -## Out of scope - -- Presets (`/.config/presets/`) -- Migration between schema versions (e.g. renaming a control). Today, an unknown JSON key is silently ignored and a missing key keeps the default. -- Runtime add/remove via UI (the underlying mechanism is in place — `replaceChildAt`, factory creation, lifecycle propagation — but no UI endpoint yet calls into it). - -## Source - -[FilesystemModule.cpp](../../../../src/core/FilesystemModule.cpp) · [FilesystemModule.h](../../../../src/core/FilesystemModule.h) diff --git a/docs/moonmodules/core/archive/FirmwareUpdateModule.md b/docs/moonmodules/core/archive/FirmwareUpdateModule.md deleted file mode 100644 index d3198967..00000000 --- a/docs/moonmodules/core/archive/FirmwareUpdateModule.md +++ /dev/null @@ -1,71 +0,0 @@ -# FirmwareUpdateModule - -![FirmwareUpdateModule controls](../../../assets/core/FirmwareUpdateModule.png) - -A thin status surface for OTA flashing. The flash itself is driven by `POST /api/firmware/url` in HttpServerModule, which hands the URL to `platform::http_fetch_to_ota` (a task that downloads via `esp_https_ota` and writes the next OTA partition). The task and this module communicate through shared file-scope globals; the module polls them in `loop1s()` and the existing WebSocket state push surfaces the change at 1 Hz. - -## Controls - -| Name | Type | Description | -|---|---|---| -| `version` | read-only string | Pure semver (`MM_VERSION`). A stable release is a clean `X.Y.Z` (e.g. `2.0.0`); a moving `latest` build is a monotonic prerelease `<core>-dev.<N>` (e.g. `2.1.0-dev.7`, where `N` is the commit count since the last `vX.Y.Z` tag — see `moondeck/build/compute_version.py`), so successive `latest` builds are orderable (semver.org §9/§11); a local/dev build carries library.json's bare `<core>-dev`. The prerelease suffix marks a not-yet-released build; a clean `X.Y.Z` is a stable release. The release channel is derivable from the version (prerelease suffix → not stable), so it is not mixed into this string — the version stays a clean, machine-comparable semver, which the UI's "update available" check compares against the newest GitHub release (stable, and the moving `latest` for devices already on a `-dev` build). | -| `build` | read-only string | Build date/time (`MM_BUILD_DATE`). | -| `firmware` | read-only string | Build-time firmware variant key from `src/core/build_info.h` (`MM_FIRMWARE_NAME`): `esp32`, `esp32-eth`, `esp32-16mb`, `esp32s3-n16r8`, … for the shipped firmware variants (the full list is the `FIRMWARES` dict in `build_esp32.py`); `desktop-macos-arm64` / `desktop-windows-x64` for packaged desktop binaries; `desktop-dev` for unpackaged local desktop builds. A device carrying the legacy `esp32-eth-wifi` key OTA-maps to `esp32`. Identifies which release asset matches the device — the same key appears in the firmware filenames published by `release.yml`. The compiled binary; the physical hardware it runs on is SystemModule's `deviceModel` control. `install-picker.js`'s `isCompatible()` reads this string. | -| `firmwarePartition` | progress (used/total) | Running app image size / total firmware (app) partition size — how full the partition is. Named distinctly from the `firmware` string control so a `controls.find(c => c.name === "firmware")` caller resolves the string, not this progress value. | -| `update_pct` | progress (bytes/total) | Live byte counters rendered as "X KB / Y KB"; `total` is 0 until `esp_https_ota_get_image_size` reports it just after the TLS handshake. The name is historical (it predates the percent→bytes migration); the wire shape is bytes. | - -The OTA flash phase (`idle`, `starting`, `downloading`, `flashing`, `rebooting`, `error: <reason>`) is not a control — it surfaces through the module's shared status slot (`MoonModule::setStatus()`), the same per-module banner every module uses (NetworkModule's IP line, DevicesModule's sweep count). An `error:` prefix maps to `Severity::Error`; `idle` clears the banner; everything else is neutral `Severity::Status`. - -## Wire contract - -### `POST /api/firmware/url` - -Request body: - -```json -{ "url": "https://github.com/MoonModules/projectMM/releases/download/v1.0.0/firmware-esp32-v1.0.0.bin" } -``` - -Response: - -- `202 Accepted` `{"ok":true}` — task spawned; UI watches the module status slot + `update_pct` for progress. -- `400` — missing URL, or URL doesn't start with `http://` / `https://`. -- `500` — task failed to spawn (rare; out of memory). -- `501` — platform doesn't support OTA (desktop returns this; `if constexpr (mm::platform::hasOta)`). - -The route returns immediately. Real progress streams via the module status slot + `update_pct` over the same WebSocket the UI uses for everything else. - -### Compatibility - -The OTA caller is responsible for picking a binary compatible with the running device. The web UI's install-picker enforces this via `src/ui/install-picker.js`'s `isCompatible()` — strip `-eth*` from both sides, equal identities are compatible. So `esp32` and `esp32-eth` are mutually OTA-compatible (same chip, different feature flags), as is the legacy `esp32-eth-wifi` key a device may carry (it strips to `esp32`); `esp32s3-n16r8` is only itself. Flashing the wrong firmware's binary fails at `esp_https_ota_begin` (chip family mismatch) or boot (partition table mismatch) — recoverable by re-flashing over USB, not the brick. - -## Lifecycle on flash - -1. UI sends `POST /api/firmware/url`. Route writes `"starting"` to `g_otaStatus`, resets `g_otaPct` to 0. -2. Platform task starts. Sets status to `"downloading"`. -3. `esp_https_ota_begin` opens the connection, follows redirects (GitHub release URLs 302-redirect to `release-assets.githubusercontent.com`). Status flips to `"flashing"`. -4. `esp_https_ota_perform` loops; `update_pct` advances 0 → 100. -5. `esp_https_ota_finish` commits the new image to the next OTA partition and flips the boot pointer. -6. Status flips to `"rebooting"`. 600 ms delay (HTTP response makes it to the browser first). `esp_restart()`. -7. Device boots into the new firmware. UI auto-reconnects via WS, picks up the new `version` + `firmware` on this Firmware card. - -## Errors - -The status buffer surfaces any failure with the prefix `error: ` followed by the underlying cause: - -- `error: ota begin <ESP-IDF error name>` — connection or partition-init failure (DNS, TLS, no OTA partition). -- `error: ota perform <ESP-IDF error name>` — mid-download failure (network drop, server error). -- `error: incomplete download` — image size doesn't match what was claimed. -- `error: ota finish <ESP-IDF error name>` — commit / boot-pointer-flip failure. -- `error: task create failed` — `xTaskCreate` returned non-`pdPASS` (out of memory). No retry; reboot. - -After an error, the status slot stays on the error message until the next `/api/firmware/url` POST clears it back to `"starting"`. `update_pct` is left at the last value. - -## Prior art - -- **projectMM-v1** had this module + the route + the platform helper, structured the same way: `src/modules/system/FirmwareUpdateModule.h` (display surface), `src/core/OtaState.h` (shared globals), `src/core/AppRoutes.cpp:174-210` (the route), `src/pal/Pal.h` (`pal::http_fetch_to_ota`). -- **`esp_https_ota`** is the standard ESP-IDF OTA-from-HTTP component, used by every OTA flow on ESP32 since IDF v4.x. The install-picker UI is the new layer on top. - -## Source - -[FirmwareUpdateModule.h](../../../../src/core/FirmwareUpdateModule.h) diff --git a/docs/moonmodules/core/archive/HttpServerModule.md b/docs/moonmodules/core/archive/HttpServerModule.md deleted file mode 100644 index 936fca3b..00000000 --- a/docs/moonmodules/core/archive/HttpServerModule.md +++ /dev/null @@ -1,86 +0,0 @@ -# HttpServerModule - -Embedded HTTP server + WebSocket. Serves the web UI and the REST API that backs it. - -> This page is the end-user / API-integrator view of the module. The C++ interface lives in [`src/core/HttpServerModule.h`](../../../../src/core/HttpServerModule.h) (+ `.cpp`); facts visible there (private helpers, member layout, lifecycle methods) aren't repeated here. See [CLAUDE.md § Documentation](../../../../CLAUDE.md) for the rule. - -Controls: `port` (uint16_t, default 8080 on desktop / 80 on ESP32). - -## REST API - -```text -GET / → index.html -GET /app.js, /style.css → UI assets -GET /moonlight-logo.png → header logo + favicon - -GET /api/state → full module tree JSON: each entry carries - name, type, role, enabled, loopTimeUs, - classSize, dynamicBytes, controls[], - status + severity (only when set by the - module; severity ∈ status/warning/error), - plus userEditable:false ONLY when the module - opts out of UI delete/replace (omitted = editable) -GET /api/system → fps, tickTimeUs, freeHeap, freeInternal, - maxBlock, uptime -GET /api/types → {types:[{name, displayName, role, docPath, - tags, dim, acceptsChildRoles, defaults}]} - name is the stable factory key - ("RainbowEffect"); displayName is the - role-suffix-stripped UI label ("Rainbow"); - acceptsChildRoles is the comma-separated child - roles this type accepts (""=none); defaults is - captured from a fresh probe instance per type - -POST /api/control → {module, control, value} -POST /api/modules → {type, id?, parent_id?} — create -POST /api/modules/{name}/move → {to: N} — reorder to absolute index N - within parent. Strict-suffix match; - /movex → 404. Triggers Scheduler::buildState() - so LUT-affecting reorders rebuild. -POST /api/modules/{name}/replace → {type} — swap at the same position. - Strict-suffix. Replacement starts with - factory defaults; siblings + order kept. -POST /api/reboot → calls platform::reboot() - (esp_restart on ESP32, std::exit(0) on desktop) - -DELETE /api/modules/{name} → remove module by name, teardown, rebuild -``` - -All JSON responses stream through a `JsonSink` — no fixed-buffer ceiling, so a tree of any size serialises correctly. - -## WebSocket - -`GET /ws` with `Upgrade: websocket` → RFC 6455 handshake (SHA-1 + base64). Up to 4 concurrent clients. - -- **Server → client text frames:** full state JSON, pushed by `loop1s()`. -- **Server → client binary frames:** two paths, both with no frame-sized buffer. - - **Synchronous stream** — `beginBinaryFrame(totalLen)` / `pushBinaryFrame(data,len)` / `endBinaryFrame()`: `begin` sends the WS header (16-bit, or the 64-bit form above 64 KB) to every client; each `push` fans a payload slice to every client; `end` returns whether every client got the whole frame. For a forward-only producer that builds the payload as it goes (PreviewDriver's coordinate table and downsampled colour frames, walked from `forEachCoord`). Each push spins `writeSome` a bounded number of times for the lwIP buffer to drain, then closes a client that can't keep up. These frames are small/infrequent, so the bounded spin is fine. - - **Resumable buffered send** — `sendBufferedFrame(header, headerLen, body, bodyLen)`: for a payload that lives in a **stable caller-owned buffer** (PreviewDriver's full-res colour frame, whose body is the driver buffer). The header is copied; `body` is a pointer the caller keeps stable. One WS message is then **drained a memory-adaptive chunk per client per `loop20ms`** via `writeSome` — so a large frame is delivered over wall-clock ticks **without spinning any loop**, yet stays one atomic WS message to the browser. One send in flight at a time: a new `sendBufferedFrame` while one is active is **dropped** (newest-wins backpressure → the producer reads "link busy"). `bufferedSendIdle()` reports when the previous frame finished draining; `cancelBufferedSend()` abandons an in-flight send before its `body` is freed (a geometry rebuild). The chunk size comes from `maxAllocBlock()` so a tight board takes small bites (bounded tick cost) and a roomy board drains fast. -- **Client → server:** none. Mutations go through the REST API. - -Both paths are domain-neutral (the server doesn't interpret the bytes). The resumable drain runs on **`loop20ms` (the 20 ms transport-poll), deliberately NOT the per-render-tick `loop()`** — pushing preview bytes to the socket must not be charged to the LED render hot path. The LED path (the driver output) is never delayed by the preview; the preview frame rate is instead bounded by the 20 ms drain cadence (a few fps at large full-res frames, higher for small grids), which is the right trade since the preview is a *view* and the LEDs are not. The resumable path lets a 128²+ full-res frame stream on a slow link without stalling the device: the effective frame rate self-limits (the next frame waits for `bufferedSendIdle()`), so the link sheds frame rate gracefully instead of freezing. When the two-core render/transport split lands ([architecture.md § Parallelism](../../../architecture.md#parallelism)) the drain moves to the transport core and the cadence limit lifts — `loop20ms` is already that seam. - -## WLED-compatibility shim - -A small set of WLED-shaped messages make a projectMM device appear in — and be controlled from — the **native WLED apps** (iOS / Android) and Home Assistant's WLED integration. The flow, reverse-engineered from the WLED-Android client (`DeviceDiscovery.kt`, `DeviceFirstContactService.kt`, `WebsocketClient.kt`): - -1. **Discover** over mDNS `_wled._tcp` (advertised by the platform). The app resolves the service to an IP — no TXT field is required. -2. **Validate** with `GET /json/info`, parsing it into its `Info` Moshi model. The model's **non-nullable** fields — `name`, `leds` (object), `wifi` (object) — gate acceptance: a missing one fails the parse and the device is silently dropped. An empty body `mac` is also rejected. So the served object is the minimal accepted one: `{name, mac, leds{}, wifi{}, brand:"WLED", product:"MoonModules"}`. `brand:"WLED"` is what the app keys on to accept it — we interoperate, not impersonate (`product` says what it is). This is **not** a full WLED emulation. -3. **Live state** comes over the **WebSocket** at `/ws`, NOT an HTTP GET — the app's `DeviceApi` has no state-GET; `WebsocketClient` parses each `/ws` text message as a `DeviceStateInfo` = `{state, info}`. So `pushWledStateToWebSockets()` pushes a `{state, info}` frame to every `/ws` client (alongside the module-state frame our own UI reads — each consumer keys on its own top-level shape and ignores the other). `state` is `{on, bri, seg:[{id:0, col:[[r,g,b]]}]}`: `on`/`bri` mirror the **Drivers** `brightness` control (off = 0); `col[0]` is the **live first-LED RGB**, so the app tints the device card with what the device is actually showing, falling back to projectMM **purple `[128,0,255]`** when the first LED is black/off. -4. **Control is bidirectional and goes over the same `/ws`.** The app's main list slider + toggle **SEND** state — a `{on?, bri?}` text frame — over the WebSocket (`sendState`), NOT via HTTP POST. So `pollWledStateFromWebSockets()` (on the `loop20ms` transport poll) reads each client's inbound frame, unmasks it (client→server frames are masked, RFC 6455 §5.3), and applies `{on, bri}` to the Drivers `brightness` control via the shared apply-core (`applySetControl`, the same path `/api/control` and Improv `APPLY_OP` use): `on:false` → 0; `on:true` with no `bri` → a visible default; `bri:N` → set N. Without this read the slider would snap back — we'd push our unchanged brightness on the next frame. The HTTP `POST /json/state` (used by the app's system quick-tiles + Home Assistant) shares the same `applyWledState` logic and echoes the resulting state; `GET /json/state` and `GET /json/si` are also served for direct HTTP clients. - -The colour read is the one place this core module reaches output state: `MoonModule::firstOutputRgb(uint8_t[3])` is a **domain-neutral virtual** (core declares it returning false; the light-domain `Drivers` overrides it to read pixel 0 of whichever buffer it is driving). This keeps HttpServerModule free of any light-domain include — the same boundary the preview path holds. - -## Cross-domain wiring - -HttpServerModule is core infrastructure with **no** light-domain dependencies — no `PreviewFrame`, no light types, no light includes. It exposes the `BinaryBroadcaster` interface (the synchronous `beginBinaryFrame` / `pushBinaryFrame` / `endBinaryFrame`, the resumable `sendBufferedFrame` / `bufferedSendIdle` / `cancelBufferedSend`, and `clientGeneration`); the light-domain `PreviewDriver` holds a `BinaryBroadcaster*` and streams each frame's bytes through it. `main.cpp` wires `PreviewDriver`'s broadcaster to the HttpServerModule instance — the only file that knows both. The preview's point budget and wire format are PreviewDriver's concern, documented there. - -## Prior art - -### WLED native app — [WLED-Android](https://github.com/Moustachauve/WLED-Android) by Christophe Gagnier ([@Moustachauve](https://github.com/Moustachauve)) - -The WLED-compatibility shim's exact field requirements were reverse-engineered from this client's source: `DeviceDiscovery.kt` (mDNS `_wled._tcp` browse), `DeviceFirstContactService.kt` (the `/json/info` validation + non-empty `mac` check), the `Info`/`State` Moshi models (the non-nullable `name`/`leds`/`wifi` fields that gate acceptance), and `WebsocketClient.kt` (live state over `/ws`, the `sendState` control direction). Credit to @Moustachauve — knowing precisely what the app reads is why the shim is the minimal accepted object rather than a guessed full WLED emulation. - -## Source - -[HttpServerModule.cpp](../../../../src/core/HttpServerModule.cpp) · [HttpServerModule.h](../../../../src/core/HttpServerModule.h) diff --git a/docs/moonmodules/core/archive/I2cScanModule.md b/docs/moonmodules/core/archive/I2cScanModule.md deleted file mode 100644 index f7db6711..00000000 --- a/docs/moonmodules/core/archive/I2cScanModule.md +++ /dev/null @@ -1,27 +0,0 @@ -# I2cScanModule - -A **core**, domain-neutral diagnostic that scans an I2C bus and reports which device addresses ACK — the standard [`i2cdetect`](https://manpages.debian.org/i2c-tools/i2cdetect.8.en.html) operation, surfaced in the UI. It is the bring-up tool for any I2C peripheral (an audio codec, a sensor, a port expander): set the bus pins, press scan, read off the addresses present. Confirms wiring before a driver tries to talk to the device. - -Not auto-wired. Factory-registered like [AudioModule](AudioModule.md), so a board with an I2C bus adds it through `web-installer/deviceModels.json` (its `sda`/`scl` controls carry that board's bus pins) or the user adds it from the UI. - -## Controls - -- `sda` / `scl` — the bus pins ([Pin](Control.md) controls). Default to **GPIO21/22**, the Arduino-ESP32 core's conventional I2C pair, so the control pre-fills a sensible starting point on a classic ESP32 (the pins route through the GPIO matrix, so they're a convention, not fixed hardware). A board with a fixed bus overrides them in its catalog entry — e.g. the S31's `sda:51, scl:50`. -- `scan` — a **button** (momentary action, [Control.md](Control.md)): pressing it runs the scan now (`onUpdate` → the probe). A button, not a toggle, because it's a one-shot action. -- `result` — a read-only string of the 7-bit addresses found, space-separated hex (e.g. `0x18 0x3c`); empty when none answer. - -Scan state ("N devices found", "set sda + scl pins first") reports through the standard [MoonModule](MoonModule.md) `setStatus()` channel. - -## How it works - -The probe is `platform::i2cScan(sda, scl, out, maxOut)` (declared in `src/platform/platform.h`). That seam is self-contained: it opens a **temporary** I2C master bus on the given pins, probes every 7-bit address (`0x01`–`0x77`), writes the ACKing addresses into the caller's buffer, and tears the bus down. Opening its own short-lived bus (rather than borrowing one) means the scan never conflicts with a bus another driver owns — e.g. the ES8311 codec on the ESP32-S31 holds its own bus in `platform_esp32_es8311.cpp`; the scan probes the same pins independently between codec operations. - -On a target without an I2C bus (the inert stub: an I2C-less ESP32, or desktop) the seam returns `kI2cBusUnavailable`, so the scan reports "bus unavailable" rather than a misleading "0 devices found" — the 0 is reserved for a real scan where nothing ACKed. - -## Prior art - -The bus-scan-as-a-feature mirrors MoonLight's I2C scan diagnostic; the seam name and probe range follow the Linux `i2c-tools` `i2cdetect` convention. - -## Source - -[I2cScanModule.h](../../../../src/core/I2cScanModule.h) diff --git a/docs/moonmodules/core/archive/ImprovProvisioningModule.md b/docs/moonmodules/core/archive/ImprovProvisioningModule.md deleted file mode 100644 index 747eb759..00000000 --- a/docs/moonmodules/core/archive/ImprovProvisioningModule.md +++ /dev/null @@ -1,84 +0,0 @@ -# ImprovProvisioningModule - -**What Improv is.** [Improv-Wifi](https://www.improv-wifi.com/) (from Nabu Casa, the Home Assistant / ESPHome company) is an open standard for handing a device its WiFi credentials over a *local* link — USB serial here (it also has a BLE variant) — at the moment it has no network yet. That's the bootstrap chicken-and-egg it solves: a freshly-flashed ESP32 isn't on your WiFi, so you can't reach it over the network to tell it the WiFi password; Improv carries that first handoff over the cable the browser is already connected to from flashing. The name is short for *improvise* — the device has no pre-configured network, so it improvises its first connection from whatever local link is already there. projectMM extends this past credentials with the `APPLY_OP` vendor RPC — "Improv = REST over serial", see below — reusing the same already-there-before-the-network link to push the whole device config (including the deviceModel identity, which is just one of the config controls). - -Browser-driven WiFi provisioning over USB-serial, using the [Improv-WiFi](https://www.improv-wifi.com/) protocol. Bridges credentials from a Chrome / Edge / Opera tab — or from `moondeck/build/improv_provision.py` for rack/CI use — into `NetworkModule::setWifiCredentials`, which writes the same buffers the AP-fallback UI flow uses. The protocol parser + UART task live in the platform layer; this module is the status surface that polls a ready-flag and bridges credentials to NetworkModule on the scheduler thread. - -A code-wired child of NetworkModule. The wiring calls `markWiredByCode()` so the persistence-apply step preserves the child across reboots even on devices whose `Network.json` predates the addition (see [Persistence — code-wired children](../../../architecture.md#persistence)). - -The browser flow runs immediately after a Web Serial flash (ESP Web Tools recognises Improv-capable firmware and offers a "Connect to Wi-Fi?" dialog automatically). The CLI flow uses [`moondeck/build/improv_provision.py`](../../../../moondeck/build/improv_provision.py) over the same USB cable for headless or rack provisioning. - -## Controls - -| Name | Type | Description | -|---|---|---| -| `provision_status` | read-only string (64 chars) | One of: `listening`, `received credentials`, `connecting`, `connected: <ssid>`, `error: <reason>`, or `not supported on this platform` (desktop). | - -## ESP32-S3 USB-port footnote - -The listener serves **both** serial transports: UART0 (external USB-to-UART bridges) and the S3's native USB-Serial-JTAG port — boards that only expose native USB (the ESP32-S3 N16R8 Dev among them) provision over that port directly (proven on the bench 2026-06-10). If neither serial path is available, the AP-mode flow remains: the device boots a SoftAP at `4.3.2.1`, join from a phone, enter credentials. - -## Wire contract - -Both transports speak the same Improv-WiFi serial protocol — frames of `IMPROV` + version byte + type + length + payload + checksum. Full protocol details: <https://www.improv-wifi.com/serial/>. The magic, version, payload cap, and frame types come straight from the device source (this is the authority — edit the header, the doc follows): - -```cpp ---8<-- "src/core/ImprovFrame.h:frame-constants" -``` - -The on-device implementation supports four standard RPC commands plus two vendor extensions: - -- `GET_CURRENT_STATE` — returns "authorized" or "provisioned" depending on whether WiFi STA is connected. -- `GET_DEVICE_INFO` — returns `[firmware, version, chipFamily, deviceName]` (where `firmware` = `"projectMM"`, `version` from `kVersion` in `build_info.h`, `chipFamily` from `platform::chipModel()`, `deviceName` from `SystemModule`). -- `GET_WIFI_NETWORKS` — runs a synchronous WiFi scan, returns up to 10 SSIDs with RSSI + auth flag. **Rejected while STA is connected** (see below). -- `WIFI_SETTINGS` — writes SSID + password to NetworkModule via `setWifiCredentials`, polls `wifiStaConnected()` for up to 30 s, replies with success (carrying `http://<ip>/`) or `ERROR_UNABLE_TO_CONNECT`. -- `SET_TX_POWER` (vendor, `0xFD`) — payload `[1][dBm]` (0–21; 0 lifts the cap); persists + applies `Network.txPowerSetting` **before** any association attempt. This is the provisioning escape hatch for boards whose LDO browns out at full TX power (a weak LDO / marginal supply): the cap MUST land before the first association or the board fails WiFi auth at 20 dBm before it is ever online. `improv_provision.py --tx-power 8` (and the MoonDeck flow) sends this ahead of the credentials; error `0x81` on an out-of-range value. -- `APPLY_OP` (vendor, `0xFC`) — **"Improv = REST over serial".** Carries ONE REST operation as JSON, the same shape an HTTP `POST /api/modules` / `/api/control` body has: `{"op":"add","type":…,"id":…,"parent":…}` / `{"op":"set","module":…,"control":…,"value":…}` / `{"op":"clearChildren","parent":…}`. On the device the op is routed to `HttpServerModule`'s apply-core — the *exact same code* the HTTP handlers call — so a REST call over the network and an `APPLY_OP` over serial execute identically. (One schema caveat: the serial `add` op names the parent `parent`, while the HTTP `POST /api/modules` body names it `parent_id`. Both feed the one `applyAddModule()` core but the two transports parse different keys, so an HTTP payload is **not** a drop-in `APPLY_OP` — rename `parent_id` → `parent`. The serial key stays terse because every byte counts against the 128-byte frame.) The web installer pushes a device-model's whole catalog config this way during provisioning (a `clearChildren` pre-pass for every parent the entry adds into — plus any `replaceChildren` container — then an `add` per module + a `set` per control — **the deviceModel identity is just one of those `set` ops** on `System.deviceModel`, validated by that control's per-control validator like any other write), so the defaults apply **over the serial port the installer already owns during the flash** — which is what lets the HTTPS installer page configure an `http://` device that a browser fetch can't reach (mixed-content). Clearing every add-parent (not only `replaceChildren` containers) is what makes "apply device defaults" land the same way with or without an erase: the device-side `add` is idempotent on the module id, so on a non-erased device a persisted child of the same name would otherwise survive and the re-add be skipped. Frame payload: `[0xFC][seq][last][chunk]` — most ops are one frame; a long value (e.g. a big `pins` list) chunks across frames into a reassembly buffer, applied on the device's main loop when `last=1`. Single-buffered: the device errors a new op (`0x82`) while the previous is unconsumed. The installer paces ops open-loop (a fixed delay between frames sized to the worst-case consume window) rather than reading the ack back, so a lost op is improbable rather than impossible; each op is idempotent, so a re-flash re-applies cleanly. (To re-apply a model to an already-running device, use MoonDeck on the LAN, which talks plain HTTP REST with no mixed-content barrier.) - -**The serial listener runs on every ESP32 target, including Ethernet-only builds** (`--firmware esp32-eth*`). An eth-only build compiles in the vendor RPCs (`SET_TX_POWER`, `APPLY_OP`) plus `GET_CURRENT_STATE` / `GET_DEVICE_INFO`, so the web installer pushes a device-model's config over serial to an eth device exactly as it does to a WiFi one; the WiFi-provisioning RPCs (`WIFI_SETTINGS`, `GET_WIFI_NETWORKS`) build only on WiFi targets, where there's an STA to provision and `esp_wifi_*` is available. On eth, `GET_CURRENT_STATE` reports "provisioned" + the device URL from the Ethernet link (`platform::ethConnected()` / `ethGetIPv4`) instead of the WiFi STA. - -`WIFI_SETTINGS` and `GET_WIFI_NETWORKS` are both **rejected with `ERROR_UNABLE_TO_CONNECT` while `platform::wifiStaConnected() == true`**. The scan gate protects large installs: `esp_wifi_scan_start` puts the radio into scan mode for 2-5 s, during which inbound ArtNet packets are dropped. On a 16K-LED rig that's a visible glitch. To re-provision a running device, wipe `ssid` via the UI and reboot, then run Improv before STA reconnects. `GET_CURRENT_STATE` and `GET_DEVICE_INFO` stay available regardless — they're read-only and don't touch the radio. - -## How to test - -**MoonDeck button** (the everyday flow): - -ESP32 tab → pick the device's port → hit **Improv WiFi**. The script reads the host's currently-joined WiFi (macOS Keychain / Linux NetworkManager / Windows `netsh`) and pushes it to the device. The log pane shows `==> provisioned: http://<ip>/` on success. - -**Browser** (alternate single-device flow): - -1. Open <https://www.improv-wifi.com/> in Chrome / Edge / Opera on desktop. -2. Click **Connect**, pick the device's USB-serial port from the dialog. -3. Device name + chip + version appear in the page; click "Scan" to enumerate WiFi networks. -4. Enter SSID + password → page advances "connecting" → "connected" + clickable URL → opens the device UI. - -**CLI** (rack / CI / scripted): - -```bash -# Reuse the host's currently-joined WiFi (same path as the MoonDeck button): -uv run moondeck/build/improv_provision.py --port /dev/tty.usbserial-XXXX - -# Or override to push a different network's credentials: -uv run moondeck/build/improv_provision.py \ - --port /dev/tty.usbserial-XXXX \ - --ssid "MyWiFi" \ - --password "hunter2" -# Exit 0 + prints "==> provisioned: http://<ip>/" on success. -``` - -For multiple devices on a USB hub: - -```bash -for port in /dev/tty.usbserial-*; do - uv run moondeck/build/improv_provision.py --port "$port" -done -``` - -## Prior art - -- **projectMM-v1's `deploy/wifi.py` + `deploy/flashfs.py --wifi`** — wrote credentials to a local `data/state/sta1.json`, baked them into a LittleFS partition image, esptool-flashed the partition to each `test: true` device in `deploy/devicelist.json`. Same rack-provisioning use case; Improv replaces the partition-baking-and-reflashing path with live serial provisioning (devices stay running, no flash mode required). -- **Improv-WiFi** is the standard ESPHome / Home Assistant uses for cross-firmware WiFi provisioning. Library: `improv/improv` on the [ESP Component Registry](https://components.espressif.com/components/improv/improv) (source: <https://github.com/improv-wifi/sdk-cpp>); specification: <https://www.improv-wifi.com/serial/>. - -## Source - -[ImprovProvisioningModule.h](../../../../src/core/ImprovProvisioningModule.h) diff --git a/docs/moonmodules/core/archive/MoonModule.md b/docs/moonmodules/core/archive/MoonModule.md deleted file mode 100644 index 72405cfd..00000000 --- a/docs/moonmodules/core/archive/MoonModule.md +++ /dev/null @@ -1,92 +0,0 @@ -# MoonModule - -The base class for everything in the system. Effects, modifiers, layouts, drivers, and system services all inherit from MoonModule. - -## Memory target - -Aim for the smallest possible base class. Name stored in flash (progmem/constexpr), not in instance memory. Target: zero bytes of instance overhead beyond the vtable pointer and control variables. Every byte costs — on ESP32 without PSRAM, dozens of modules are loaded simultaneously. - -Field order optimized for minimal padding: group 8-byte fields, then 4-byte, then 2-byte, then 1-byte. This avoids alignment waste. - -## Lifecycle - -`setup()` / `teardown()` bracket the module's life; `loop()` / `loop20ms()` / `loop1s()` are the three tick rates (the [Scheduler](Scheduler.md) owns pacing). Two build hooks separate from `setup()`: `onBuildControls()` holds all `addX()` calls and can be re-run to rebuild the set (e.g. when a Select changes mode), and `onBuildState()` is the single dynamic-allocation hook (sets the module's heap-byte report), called at setup and on any reallocation trigger. Controls bind by reference, so see [Control.md](Control.md) for the rest. - -## Footprint reporting - -- `classSize()` — set once at registration via `register_type<T>()`. No per-class boilerplate. -- `dynamicMemorySize()` — heap bytes allocated by this module (set by `onBuildState()`). - -## Per-module timing - -Every MoonModule tracks `loopTimeUs()` — average microseconds per tick, computed over a 1-second window. The Scheduler times top-level modules; containers (Layer, Drivers) time their children. `publishTiming(frameCount)` recurses the tree every second to compute averages. - -`tickTimeUs` is the primary performance metric. FPS is derived from it (`1000000 / tickTimeUs`). This gives per-module cost visibility at any depth in the tree. - -## Enabled toggle - -Every MoonModule has an `enabled` property (default: true). The UI shows a checkbox in the card header to toggle it. Settable via `POST /api/control` with `control=enabled`. Serialized as `"enabled": true/false` in the module JSON (module-level, not in the controls array). - -**Semantics are owned by each module, not by the Scheduler.** The Scheduler always calls `loop()`, `loop20ms()`, and `loop1s()` regardless of `enabled`. Modules decide what "disabled" means: - -- **Rendering modules** (Layer, Drivers, effects, modifiers): early-return from `loop()` when `enabled()` is false. The buffer keeps its last state; the user sees the layer/driver freeze. This is the typical UX intent of "turn this effect off." -- **System modules** (HttpServer, Network, Filesystem): typically ignore `enabled` and keep accepting connections / serving requests, since "disable HttpServer" via the UI would lock the user out. - -**`onEnabled(bool newEnabled)`** is called once per transition by `setEnabled(b)` when the value actually flips. Override it to start/stop sockets, free buffers, switch driver pins to high-impedance, etc. Default is a no-op. Use this instead of polling `enabled()` in the hot path for one-shot transition work. - -## Parent/child - -Modules form a tree. Parent/child relationships only — no arbitrary DAG. Children run in order within their parent. Top-level modules also run in order. UI supports reordering, backed by the backend. - -### Generic children in MoonModule base - -Every MoonModule has a dynamic children array. `addChild()`, `removeChild()`, `replaceChildAt(i, fresh)`, and `moveChildTo(child, newIndex)` are implemented once in the base class — containers (Layer, Drivers, Layouts) do not override them. The array starts empty (zero allocation for leaf modules) and grows on demand during setup. This eliminates the per-container typed arrays (`effects_[]`, `drivers_[]`, `layouts_[]`) and typed add methods (`addEffect()`, `addDriver()`, `addLayout()`) that existed in earlier iterations. - -`replaceChildAt` is used by [FilesystemModule](FilesystemModule.md) at load time to swap a child whose type differs from the persisted JSON. The caller owns the lifecycle of the returned old child (typically `teardown()` + `Scheduler::deleteTree`). - -`moveChildTo(child, newIndex)` reorders a child to an absolute index 0..childCount-1. Intervening siblings shift to fill the vacated slot. Used by the UI's up/down/drag-and-drop reorder via `POST /api/modules/<name>/move {to:N}`. Returns false if `child` isn't found, `newIndex` is out of range, or the child is already at `newIndex`. After a successful move, the caller (currently `HttpServerModule::handleMoveModule`) triggers `Scheduler::buildState()` so any LUT that depends on modifier/layout order rebuilds. - -Children are distinguished by `role()` (Effect, Modifier, Driver, Layout, Generic). Containers that need role-specific iteration (e.g. Layer::loop() only calls loop() on Effects, not Modifiers) filter children by role at the call site. - -Two virtuals govern UI tree-mutation, keeping that policy on the device rather than hardcoded in the web UI (see [architecture.md § Web UI](../../../architecture.md#web-ui)): `acceptsChildRoles()` — comma-separated roles this module accepts as user-added children (`""` default; a container like Layer returns `"effect,modifier"`), surfaced per-type in `/api/types`, drives the UI's `+ add child` affordance and picker filter. `userEditable()` — whether the user may delete/replace this module (`true` default; a load-bearing child like PreviewDriver returns `false`), surfaced per-instance in `/api/state` (emitted only when false). The `+ add child` policy lives on the parent; the deletable/replaceable policy lives on the child. - -Parents own their children's lifecycle. Only top-level modules are registered with the Scheduler — parents propagate `setup()`, `onBuildControls()`, `onBuildState()`, `loop()`, `loop20ms()`, `loop1s()`, and `teardown()` to their children. This means children don't need separate Scheduler registration. - -### Lifecycle-aware add/remove - -When the UI adds or removes a child at runtime (e.g. switching an effect on a layer, adding a driver), the caller must handle lifecycle: - -- **Add at runtime:** caller calls `setup()` → `onBuildControls()` → `onBuildState()` on the new child (since the parent's own setup has already run). -- **Remove at runtime:** caller calls `teardown()` on the child before removing it. -- **Add before setup:** if children are added before `scheduler.setup()` (startup or persistence restore), the parent's own `setup()` propagates to all children — no special handling needed. - -This is needed for: effect switching, modifier add/remove, driver hot-plug, and persistence restore after reboot. - -## Status slot - -`setStatus(msg, severity)` / `status()` / `severity()` / `clearStatus()` carry a short message the module wants the user to see right now — Layer writes "buffer reduced — not enough memory" on memory degradation, NetworkModule writes "Eth: 192.168.1.210" or "No network". Severity is `Status` / `Warning` / `Error` and the UI picks the chip emoji (ℹ️ / ⚠️ / ❌). The slot stores a pointer (no copy), so callers pass flash string literals or a module-owned char buffer with stable lifetime. Wire contract (only emitted when set): `/api/state` and `/api/system` each carry `"status":"…","severity":"status|warning|error"` — see [HttpServerModule.md](HttpServerModule.md). - -## Persistence - -Module state (control values + `enabled` flag) is persisted to flash by [FilesystemModule](FilesystemModule.md). Modules themselves know nothing about persistence — they just bind variables via `addX(...)` calls in `onBuildControls()`. The Scheduler's phase 2 load hook overlays persisted values onto bound variables before any module's `setup()` runs. - -Conditional controls (e.g. fields only visible under a Select mode) are always bound, with a `hidden` flag toggled via `controls_.setHidden(i, true/false)`. This lets the persistence layer load values regardless of the live conditional state, while the UI hides them. See [FilesystemModule.md](FilesystemModule.md) and [Control.md](Control.md). - -`markDirty()` / `dirty()` / `clearDirty()` are set by HttpServerModule on every successful control mutation. FilesystemModule polls dirty flags in `loop1s()` and writes any subtree with a dirty descendant after a 2-second debounce. - -## Tests - -[Unit tests: MoonModule](../../../tests/unit-tests.md#moonmodule) — lifecycle, control binding, clear and rebuild. - -## Prior art - -### MoonLight — Node ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonBase/Nodes.h)) - -- Base ~29 bytes + vtable. Effects add only their control variables (uint8_t each). -- No std::string members (uses `Char<N>` fixed-size strings). -- `addControl()` binds to class variable by reference, stores `uintptr_t` pointer. -- `classSize()` reports actual instance size. - -## Source - -[MoonModule.h](../../../../src/core/MoonModule.h) diff --git a/docs/moonmodules/core/archive/NetworkModule.md b/docs/moonmodules/core/archive/NetworkModule.md deleted file mode 100644 index dd425567..00000000 --- a/docs/moonmodules/core/archive/NetworkModule.md +++ /dev/null @@ -1,173 +0,0 @@ -# NetworkModule - -![NetworkModule controls](../../../assets/core/NetworkModule.png) - -Manages all device connectivity with automatic fallback: Ethernet → WiFi STA → WiFi AP. One MoonModule, one UI card — the user sees "Network", not three separate technologies. - -## Priority cascade - -| Priority | Mode | When active | Teardown when superseded | -|----------|------|-------------|------------------------| -| A | Ethernet | Hardware detected, cable plugged | Never (always preferred) | -| B | WiFi STA | SSID configured, Ethernet unavailable | Yes — free WiFi memory when Ethernet connects | -| C | WiFi AP | STA fails or no SSID configured | Yes — free AP memory when STA connects | - -When a higher-priority connection becomes available, lower ones are torn down to reclaim memory. When a higher-priority connection drops, the next one activates automatically. AP is always the last resort. - -**AP shutdown delay**: when STA connects successfully, AP stays active for 10 seconds with a message in the UI ("AP shutting down, switch to your local network") before tearing down. This gives the user time to reconnect via STA. - -## Controls - -- `mode` (read-only) — current state of the cascade: `Ethernet`, `WiFi STA`, `WiFi AP`, `Ethernet (waiting)`, `WiFi STA (waiting)`, or `Idle`. Always present (every firmware variant has a mode, even Ethernet-only). -- `ssid` (text) — WiFi STA network name -- `password` (password) — WiFi STA password. Serialized to the API XOR-obfuscated + base64-encoded, not in plaintext — a first line of defence only, trivially reversible. See [ui.md § Control types](ui.md#control-types). -- `rssi` (display-int, dBm) — current WiFi STA signal strength (e.g. `-58 dBm`). 1-byte storage on the device; the unit suffix lives in the descriptor, not in a per-control buffer. Hidden in every state except `ConnectedSta` — Ethernet/AP/Idle have no STA association to read from. -- `txPower` (display-int, dBm) — current WiFi transmit power (e.g. `19 dBm`). 1-byte storage. Hidden when the radio is off (Ethernet, Idle); shown for STA (waiting + connected) and AP modes. -- `txPowerSetting` (int16, 0..21 dBm) — user-settable cap on WiFi transmit power. 0 = no override (default). Applied after `esp_wifi_start()` and re-applied on change. Used by the weak-power / brown-out WiFi cap: `deviceModels.json` injects `8` for boards whose on-module LDO brown-outs at full power (e.g. the `ESP32-S3 N16R8 Dev`). -- `addressing` (dropdown: DHCP / Static) — IP addressing mode (applies to both Ethernet and WiFi STA) -- When Static: `ip`, `gateway`, `subnet`, `dns` (ipv4 controls — 4 bytes of storage each, not 16-char strings; the wire shape is still a dotted-quad string). Shown dynamically via onBuildControls. -- `mDNS` (bool) — enable/disable mDNS responder - -**Ethernet PHY/pin controls** (only on builds with an Ethernet driver — `platform::hasEthernet`). The PHY *driver* is compiled into the firmware per chip (internal-EMAC RMII on classic/P4, internal-EMAC RGMII on the S31, W5500 SPI on the S3); these controls pick *which* PHY a board uses and *on which pins* — runtime config, set per board in [`deviceModels.json`](../../../../web-installer/deviceModels.json) (→ `setEthConfig` → `ethInit`), seeded from the per-chip default in `platform_config.h`. `ethType` is the switch: with it at 0 no pin rows show; choosing a type reveals only that type's editable pins (RMII rows for LAN8720/IP101, SPI rows for W5500; the S31's YT8531 RGMII shows just PHY-address + reset, since its data/clock pins are the chip's fixed IO_MUX pads). A W5500 change applies **live** (the SPI driver tears down + re-inits, no reboot); an RMII/RGMII change saves and applies on the next boot (status hints "restart to apply"). See [architecture.md § Config provenance](../../../architecture.md#config-provenance-mcu-devicemodel). -- `ethType` (select) — PHY type dropdown, options `None` / `LAN8720` / `IP101` / `W5500` / `YT8531` (stored as the index 0..4, matching the `EthPhyType` enum: 0 = none, 1 = LAN8720 RMII, 2 = IP101 RMII, 3 = W5500 SPI, 4 = YT8531 RGMII — the S31's on-chip 1 Gb EMAC). -- `ethPhyAddr` (pin) — SMI/PHY address (typically 0 or 1). -- `ethRstGpio` (pin) — PHY reset GPIO (−1 = none / module self-resets). -- `ethMdcGpio`, `ethMdioGpio` (pin) — RMII SMI clock / data GPIOs (−1 = IDF default). RMII only. -- `ethClockGpio` (pin) — RMII 50 MHz reference-clock GPIO. RMII only. -- `ethClockExtIn` (bool) — RMII clock direction: on = clock fed IN by the board, off = chip drives it OUT. RMII only. -- `ethSpiMiso`, `ethSpiMosi`, `ethSpiSck`, `ethSpiCs`, `ethSpiIrq` (pin) — W5500 SPI pins (`ethSpiIrq` −1 = polling). W5500 only. - -(The `pin` controls are `ControlType::Pin` — a one-byte `int8_t` rendered as a plain number input, not a slider: a GPIO has no meaningful range to drag, and the P4 uses pins up to 52. −1 marks an unused/default pin.) - -No `status` *control*; the module surfaces its state via the generic `MoonModule::status()` slot — "Eth: 192.168.1.210", "WiFi: 10.0.0.5", "AP: MM-XXX @ 4.3.2.1", or "No network". The UI renders it as a chip in the card header (ℹ️ when connected, ❌ when no network) rather than a control row. - -Dynamic controls: `addressing` toggling shows/hides the static-IP fields. State transitions (cascade up to Ethernet, fall back to AP, STA reconnect) trigger a rebuildControls() so the rssi/txPower hidden flags re-evaluate. The metric strings refresh every loop1s() tick — same buffer addresses, so no rebuild is needed for value updates, only for visibility. - -AP always uses fixed IP `4.3.2.1` (easy to remember, avoids 192.168.x.x conflicts with home routers). - -## Device name - -NetworkModule reads the deviceName from SystemModule (see [SystemModule.md](SystemModule.md)) for: -- **mDNS**: device responds to `name.local` -- **AP SSID**: when in AP mode, the network name is the deviceName - -## mDNS - -Included in NetworkModule (not separate). Registers the deviceName on whichever interface is active. Re-registers when the active interface changes. Uses ESP-IDF's `mdns_init()` / `mdns_hostname_set()`. - -## `MM_IP=` boot token (web-installer contract) - -`NetworkModule::currentIp(out)` writes the device's current LAN IP as octets into a caller-owned `uint8_t[4]` (all-zero when not connected — the module holds no IP of its own; the IP already lives as the netif binding). `main.cpp` formats it with `formatDottedQuad` and appends the token to its tick line **for the first 60 s of uptime only** — long enough for the installer (which reads at ~3–15 s after boot), after which the IP comes from the REST API and the perf line stays clean. `main.cpp`'s once-per-second tick log appends it as a machine-parseable `MM_IP=<ip>` token whenever there's an IP — so it reaches the USB-CDC console via `std::printf` (unlike `ESP_LOGI`, which on some chips goes only to the UART pins) and re-emits every second for the whole uptime: - -``` -tick: 412us (FPS: 60) free: 184320 maxBlock: 110592 MM_IP=192.168.1.210 -``` - -The web installer reads this token from the device's boot serial log right after flashing (`readBootIp` in `install-orchestrator.js`): a device that comes up on Ethernet, or boots with saved WiFi credentials, announces its own IP — so the installer auto-adds it to *Your devices* and skips the "type the device's IP" prompt, with no mDNS/hostname guessing (works on every OS, survives a renamed `deviceName`). Because the token rides the already-periodic tick line, the read is timing-independent — whenever the installer reopens the port, the next tick carries the IP. Deliberately IP-only — once the installer has the IP it reads anything else (chip, firmware, modules) from the live REST API. A fresh device with no credentials never connects, so the token never appears, and the installer falls back to Improv provisioning + manual entry. - -## Ethernet - -Ethernet init is part of `NetworkModule::setup()`, which calls `syncEthConfig()` (pushes the eth controls above into `platform::setEthConfig`) then `platform::ethInit()`. The PHY type and pins are **runtime** config (the `ethType` + pin controls above), not compile-time — see the controls list and [architecture.md § Config provenance](../../../architecture.md#config-provenance-mcu-devicemodel). - -Which Ethernet *driver* is compiled in is per chip (the firmware variant): classic/P4 carry the internal-EMAC RMII driver, the S3 the W5500 SPI driver; a build with no Ethernet driver (`MM_NO_ETH`) stubs `ethInit()` to return false. When no PHY responds (no cable, or no hardware), `ethInit()` returns false and the cascade falls through to WiFi STA → AP — no GPIO grab, no hang. - -## Memory - -| Mode | Heap cost | Notes | -|------|----------|-------| -| Ethernet only | ~20KB | lwIP + Ethernet driver | -| WiFi STA only | ~40KB | WiFi driver + lwIP | -| WiFi AP only | ~30KB | AP driver + DHCP server | -| STA + AP (during transition) | ~60KB | Both active during AP shutdown delay | -| After teardown | 0 | Memory fully reclaimed | - -### Boot order: network before light buffers - -NetworkModule must be registered with the Scheduler **before** Layer and Drivers. The Scheduler runs setup() sequentially by registration order. This ensures network memory is claimed first, and the light pipeline's adaptive allocation (`canAllocate()`) sees the actual remaining heap — not an optimistic number that shrinks later when WiFi starts. - -### Runtime transitions - -When the network mode changes (e.g. STA drops → AP starts, or Ethernet connects → WiFi torn down), the available heap changes significantly. The transition follows a safe sequence: - -1. **Check heap**: estimate memory needed for the new network mode -2. **If heap is tight**: tear down light buffers first (free Layer buffer, LUT, driver buffer) — display goes dark temporarily -3. **Start new network mode**: WiFi/AP init claims its memory -4. **Rebuild light pipeline**: `scheduler.buildState()` re-runs `onBuildState()` — adaptive allocation uses whatever heap remains - -This ensures the system never crashes from out-of-memory during WiFi init. Temporarily dropping light buffers (going dark for a few seconds) is acceptable — crashing is not. - -After the transition settles: -- If heap shrank (AP started, +30KB used): pipeline may run degraded (smaller buffers, no LUT) -- If heap grew (WiFi torn down, +40KB freed): pipeline restores full capability - -During the AP shutdown delay (STA+AP both active, ~60KB), the pipeline runs degraded. After AP teardown, rebuild restores full buffers. - -NetworkModule reports its memory via the standard per-module system (classSize, dynamicBytes, loopTimeUs). dynamicBytes is updated after each mode change to reflect the current network stack allocation. - -## Lifecycle - -- `setup()` — init Ethernet (if hardware present). If Ethernet connects, done. Otherwise init WiFi STA. If STA fails within timeout (~10s), start AP at 4.3.2.1. -- `loop1s()` — check connection status. If active connection drops, cascade to next priority. If higher-priority becomes available, switch up (with AP shutdown delay). -- `teardown()` — disconnect all, free WiFi/Ethernet memory, stop mDNS. - -## Credential injection - -Before persistence (item 11), credentials are lost on reboot. To solve this: -- **REST API**: `POST /api/control` with module=Network, control=ssid/password — same as any control change via HTTP -- **MoonDeck**: Live tab adds a "Set WiFi" button that injects credentials into the selected device via the REST API. MoonDeck also adds a WiFi credential injection card for initial device setup. - -After persistence (item 11), credentials survive reboot automatically. - -## ESP32 only - -This module is ESP32-specific (and Teensy later). Desktop and RPi use OS-level networking — no NetworkModule loaded. The HTTP server and ArtNet work regardless because they use the platform socket abstraction which works on any connected interface. - -## Hardware availability - -Not every ESP32 has WiFi (e.g. ESP32-C2, ESP32-H2 ship without it) and not every board has Ethernet (classic ESP32 dev boards don't have a PHY). The cascade adapts to whatever the platform layer reports as present: -- If Ethernet hardware is detected (PHY responds during init), the cascade starts at Ethernet. Otherwise Ethernet is skipped. -- If WiFi hardware is present, STA + AP fallback are available. Otherwise the cascade ends after Ethernet. - -The cascade tries each interface unconditionally and relies on the platform init calls to fail fast when the hardware isn't present: `platform::ethInit()` returns false on boards without a PHY, and the WiFi STA/AP init paths return false on chips without WiFi. `onBuildControls()` binds the controls applicable to the build target: `mode` always, and the WiFi controls (`ssid`, `password`, `rssi`, `txPower`, the TX-power cap) only when `platform::hasWiFi` is true — they're compiled out of the Ethernet-only build. (`deviceName` is bound by [SystemModule](SystemModule.md), not here — NetworkModule only reads it for the AP SSID / mDNS hostname.) The gate is compile-time (the build target), not a runtime `ethInit()` result. Within a WiFi-capable build, `rssi` / `txPower` are bound but `hidden` while the radio is off (Ethernet / Idle), since the value would be stale. Cards for absent interfaces show as "no link" / "no IP" rather than hiding, because the device exposes no hardware-presence signal to the UI. - -## Ethernet-only build - -The Ethernet-only build (`build_esp32.py --firmware esp32-eth`) compiles WiFi out entirely — the platform layer reports `mm::platform::hasWiFi == false`. NetworkModule branches on that constant via `if constexpr`, so in this build: - -- The cascade is **Ethernet-only**: no STA/AP states are reachable. `setup()` enters `WaitingEth` on a successful `ethInit()`; if Ethernet fails or the cable is absent, the status reads "No network (Ethernet only)" and the module keeps polling for a cable (replug works; no reboot needed once a link appears via `WaitingEth`). -- `onBuildControls()` does **not** bind the `ssid` / `password` controls — they are absent from the UI card. -- The `addressing` selector (DHCP / Static) and the static-IP controls **remain** — static IP is valid on Ethernet. The `mDNS` toggle also remains (mDNS is interface-agnostic). - -The `ssid_` / `password_` member buffers still exist (unconditional struct layout keeps persistence stable) — they are simply never displayed or used. - -## Security - -- AP mode: open (no password) — fallback for initial setup only -- STA password stored in controls (persisted after item 11) -- No HTTPS — embedded device, local network only - -## Tests - -- Desktop: no NetworkModule tests (OS networking) -- ESP32: live scenario verifies device is reachable via the active interface -- Memory: verify WiFi teardown reclaims heap (monitor freeHeap before/after) -- Cascade: verify fallback from Ethernet → STA → AP when connections drop - -## Prior art - -### MoonLight - -- mDNS hostname advertising -- REST API for network config -- Credentials persisted to SPIFFS - -### ESP-IDF - -- `esp_wifi.h` — WiFi STA/AP init -- `mdns.h` — mDNS service -- `esp_netif.h` — network interface management -- `esp_event.h` — connection/disconnection events - -## Source - -[NetworkModule.h](../../../../src/core/NetworkModule.h) diff --git a/docs/moonmodules/core/archive/Scheduler.md b/docs/moonmodules/core/archive/Scheduler.md deleted file mode 100644 index 9b1627f3..00000000 --- a/docs/moonmodules/core/archive/Scheduler.md +++ /dev/null @@ -1,34 +0,0 @@ -# Scheduler - -Domain-neutral. Manages MoonModule lifecycles and dispatch. - -## Loop rates - -- `loop()` — hot path for effects and drivers. Called every iteration. The Scheduler handles pacing (yielding to other tasks between iterations via `taskYIELD()` on ESP32, optional sleep on desktop). -- `loop20ms()` — every ~20ms. UI updates, control reads, network polling. -- `loop1s()` — every ~1 second. Diagnostics, reconnects, housekeeping. - -Not all modules need `loop()`. System modules (HTTP, WiFi) use `loop20ms()` or `loop1s()` only. - -## Timing - -Effects use a synchronized clock for animation (millis from platform). This is frame-rate independent — same visual speed at 30fps and 60fps. For multi-device sync, the leader synchronizes this clock across devices. No frame counter needed. - -## Core affinity - -Each top-level module declares a core affinity (0 or 1 on ESP32). The scheduler pins the module's task to that core. Child modules inherit the parent's core. On single-core or desktop systems, core affinity is ignored. - -## Module ordering - -Child modules run in their declared order within the parent. Top-level modules also run in declared order. The UI supports reordering, backed by the scheduler. Only parent/child relationships — no arbitrary dependency graph. - -## Prior art - -### MoonLight — effectTask / svelteTask - -- Two FreeRTOS tasks: effects on core 1, system/drivers on core 0. -- Per-node: `loop()` every frame, `loop20ms()` for slow updates. - -## Source - -[Scheduler.cpp](../../../../src/core/Scheduler.cpp) · [Scheduler.h](../../../../src/core/Scheduler.h) diff --git a/docs/moonmodules/core/archive/SystemModule.md b/docs/moonmodules/core/archive/SystemModule.md deleted file mode 100644 index fee40610..00000000 --- a/docs/moonmodules/core/archive/SystemModule.md +++ /dev/null @@ -1,49 +0,0 @@ -# SystemModule - -![SystemModule controls](../../../assets/core/SystemModule.png) - -System-level diagnostics and device identity. Always loaded, always visible in the UI. - -## Controls (ordered by change frequency) - -**Dynamic (updates every second):** -- `uptime` (read-only, progress) — seconds since boot -- `fps` (read-only) — derived from Scheduler tickTimeUs -- `tickTimeUs` (read-only) — average tick time in microseconds -- `freeHeap` (read-only, progress) — current free heap / total heap -- `freeInternal` (read-only, progress) — current free internal heap / total internal -- `maxBlock` (read-only) — largest contiguous allocatable block - -**Configurable:** -- `deviceName` (text, default `MM-XXXX` where XXXX = last 4 hex of MAC) — the device's network identity (*which unit this is*). Used as hostname for mDNS, AP SSID, and UI display. Persisted. -- `deviceModel` (text, read-only in the UI) — the physical-hardware identity (*which product this is*, e.g. `Olimex ESP32-Gateway Rev G`), the entry name from the device-model catalog ([deviceModels.json](../../../../web-installer/deviceModels.json)). The device can't self-identify its hardware, so this is *pushed* by tooling — just like any other catalog default: the web installer sends it as one of the `APPLY_OP` `set` ops during provisioning (see [ImprovProvisioningModule.md](ImprovProvisioningModule.md)), or MoonDeck over HTTP `/api/control` on the LAN. The printable-ASCII rule (1..31 chars, 0x20–0x7E, no NUL) is a per-control validator on the descriptor (`ControlDescriptor::validate`), so *every* write path — HTTP, serial APPLY_OP, persistence load — runs it in the backend. Display-only in the UI (pushed, never user-typed at the device); persisted. - -**Static (set at boot):** -- `chip` (read-only) — chip model (ESP32, ESP32-S3, etc.) -- `sdk` (read-only) — ESP-IDF version string (or compiler on desktop) -- `wifiCoproc` (read-only) — WiFi co-processor firmware status, shown only on boards whose radio is a separate chip (the ESP32-P4 with its on-board [ESP32-C6](https://www.espressif.com/en/products/socs/esp32-c6) over [esp_hosted](https://github.com/espressif/esp-hosted-mcu)). Reports the detected slave firmware version (`C6 fw 2.12.9`) when the link is up, or `not detected` when the C6 never completes its handshake / reports 0.0.0, which is the signature of absent or incompatible C6 slave firmware. Absent on native-radio targets (the platform returns an empty string and the control is not added). -- `flash` (read-only) — total flash chip size -- `psram` (read-only, progress) — used / total PSRAM (only if present) -- `bootReason` (read-only) — human-readable reset reason from `platform::resetReason()` (e.g. `POWERON`, `SW`, `PANIC`, `INT_WDT`, `TASK_WDT`, `BROWNOUT`, `DEEPSLEEP`). Desktop always reports `OK`. The UI flags the reboot button with a red border (`data-crashed="true"`) when the value is one of PANIC / INT_WDT / TASK_WDT / BROWNOUT, indicating the prior boot ended unexpectedly. - -On desktop these show "desktop" / "N/A" for hardware-specific fields. - -## Device name - -`deviceName` is the device's identity across the system: NetworkModule uses it as the mDNS hostname (`deviceName.local`) and the AP SSID, and MoonDeck shows it in the device list. The default `MM-XXXX` derives from the last 4 hex of the MAC. - -## Tests - -- Unit test: MAC-to-name conversion -- Scenario: verify /api/system returns valid metrics - -## Prior art - -### MoonLight - -- System diagnostics via REST API -- Device name used for mDNS - -## Source - -[SystemModule.h](../../../../src/core/SystemModule.h) diff --git a/docs/moonmodules/core/archive/ui.md b/docs/moonmodules/core/archive/ui.md deleted file mode 100644 index ec7440fa..00000000 --- a/docs/moonmodules/core/archive/ui.md +++ /dev/null @@ -1,222 +0,0 @@ -# UI Specification - -This spec describes the projectMM web UI **as shipped** — minimal, MoonModule-driven, no frameworks. - -## TL;DR - -**The UI is a handful of hand-maintained files** (`index.html`, `app.js`, `style.css`, plus the `preview3d.js` and `install-picker.js` modules `app.js` imports) that render any MoonModule tree generically — no per-module-type code. State updates flow via WebSocket; mutations go through a small REST API. - -**Backend contract** — what the UI depends on: -- HttpServerModule serves `/api/state`, `/api/types`, `/api/control`, `POST /api/modules`, `DELETE /api/modules/<n>`, `POST /api/modules/<n>/move`, `POST /api/modules/<n>/replace`, `POST /api/reboot`. -- WebSocket at `/ws` pushes full-tree state JSON and an optional binary preview channel (leading type byte selects the domain renderer; the payload after is domain-defined). -- Per-control `hidden` flag is supported. -- Per-module `loopTimeUs`, `classSize`, and `dynamicBytes` are exposed in `/api/state`. - -## Principles - -- **Hand-maintained files, no toolchain**: `index.html`, `app.js`, `style.css`, plus two ES modules `app.js` imports — `preview3d.js` (WebGL 3D preview) and `install-picker.js` (shared with the web installer) — and the `moonlight-logo.png` asset. No frameworks, no build tools, no npm. All are gzipped and embedded at compile time on ESP32 via `ui_embedded.h` (the logo as a binary byte array). -- **MoonModule-driven.** The UI has zero hard-coded knowledge of specific module types or domain categories. It queries `/api/types` and `/api/state` (or its WebSocket equivalent) and renders generically — module name, role chip, controls, children — without naming any concrete type in the codebase. -- **Adding a new MoonModule with new controls requires zero UI changes** — provided the engine uses control types the UI already knows how to render. -- **WebSocket for updates, REST for mutations.** Real-time state push without polling; user actions are explicit POST/DELETE calls. -- **Two timescales for inputs.** Local UI feedback (slider value label, toggle state) updates within ~20ms of the input event. Network sends are debounced (150ms slider, 500ms text). Slider feels instant even when the server is busy. -- **No write-back races.** A `dragTs[moduleId:key]` cooldown of 1s prevents incoming WS pushes from overwriting controls the user is actively manipulating. -- **No DOM rebuilds on state updates.** Initial render builds the tree; state updates patch values in place via `querySelector` by `data-mid`/`data-key`. - -## Layout - -```text -┌─────────────────────────────────────────────────────────────┐ -│ Status bar: ☰ · logo · brand · device name · sys stats │ -│ · WS dot · reconnect · reboot · theme toggle │ -├──────────────┬──────────────────────────────────────────────┤ -│ Side nav │ Sticky preview canvas (shrinks on scroll) │ -│ (root list) ├──────────────────────────────────────────────┤ -│ │ Cards for the selected root module │ -│ │ ↳ child cards inline │ -│ ───────── │ ↳ grandchild cards inline │ -│ footer: │ │ -│ social © │ │ -└──────────────┴──────────────────────────────────────────────┘ -``` - -- Fixed status bar at top (44px) — hamburger, MoonLight logo, then the rest -- Side nav: a left column listing root modules; the selected root's card subtree fills the main area (one root visible at a time) -- Module card column max-width 500px, centered (single-column module list — easier to read on phones). The preview canvas is **not** capped — it spans the full content area width -- Preview canvas is sticky just below the status bar and shrinks 0% → 50% as the user scrolls 0 → 300px - -## Status bar - -Fixed top, 44px. Left-to-right: -1. Hamburger (☰) — toggles the side nav (`body.nav-open` class). See [Side navigation](#side-navigation). -2. MoonLight logo — 28px PNG, served from `/moonlight-logo.png`. The same image is the page favicon (`<link rel="icon">`). -3. Brand wordmark — "projectMM" -4. Device name (from `System.deviceName` control) -5. System stats — `uptime · NN K free` (uptime as `Xd Yh Zm Ws`, free heap as KB). Read from the SystemModule controls in the WebSocket state push; no separate polling endpoint. -6. WebSocket connection dot — green=connected, gray=disconnected. The socket auto-reconnects with exponential backoff (500ms → 5s) on close; no manual reconnect button — a page reload covers the rare half-dead-socket case. -7. Reboot button (⏻; red border via `data-crashed="true"` when `SystemModule.bootReason` is PANIC / INT_WDT / TASK_WDT / BROWNOUT). Press-twice to confirm: the first click arms it (turns solid red), a second click sends `POST /api/reboot`. Disarms after 3s or when the pointer leaves — no `confirm()` popup. -8. Theme toggle (☀/🌙) — flips `[data-theme]` on `<body>`; preference in `localStorage['mm_theme']`. - -## Side navigation - -A left column listing the root modules, one entry per top-level MoonModule. - -- **One root visible at a time.** Clicking a nav entry sets the selected root; `renderCards()` renders only that root's card subtree. The selection persists in `localStorage['mm_selectedRoot']`; the active entry is highlighted. -- **Hamburger toggle.** The ☰ button toggles `body.nav-open`. On wide screens (≥820px) the nav is a static column the hamburger collapses/expands. On narrow screens (<820px) the nav is a slide-in drawer over a dimming overlay; clicking the overlay or pressing Esc closes it. -- **No root reorder.** Root order is fixed in `main.cpp` — the nav does not drag-reorder. -- **Footer** pinned to the bottom of the nav: social links (GitHub, Discord, Reddit, YouTube — inline SVG icons) and a `© <year> MoonLight` copyright line. - -The WebSocket state push still carries the full module tree; only the selected root is rendered, and `updateValues()` patches just the visible cards (non-rendered roots have no DOM nodes, so their data is silently ignored). Per-root server-side filtering was evaluated and deferred — the 1 Hz push is cheap and the JSON is built through a streaming sink (see below), so tree size is not a buffer-limit concern; a bidirectional view-state protocol isn't justified. - -## Module card - -Each MoonModule renders as a card. **Child cards are nested inside their parent card's box** — the parent's border encloses its children, so the tree shape is visible structurally, not just by indentation. Nesting depth shows as progressively lighter backgrounds and a left-margin indent on the children block. - -Card structure: - -```text -┌─ card ──────────────────────────────────┐ -│ [name] [emoji] [timing · 🧠 mem] [enabled toggle] [✎ × ☰] [? help] │ -│ [control rows — one per control] │ -│ ┌─ child card ────────────────────────┐ │ -│ │ … │ │ -│ └─────────────────────────────────────┘ │ -│ ┌─ child card ────────────────────────┐ │ -│ │ … │ │ -│ └─────────────────────────────────────┘ │ -│ [+ add child] │ -└─────────────────────────────────────────┘ -``` - -- The parent's own controls render **above** its children; `+ add child` renders **below** them, at the bottom of the parent box. -- Child cards live in a `.card-children` wrapper appended into the parent card's DOM node — not as flat siblings in the main column. `renderModuleTree` recurses into the parent card, not into `main`. - -- **Enabled toggle** in the right-hand action cluster (next to ✎ × ?) mirrors `MoonModule::enabled()`. Rendered as a styled `<button>` matching the other action buttons (transparent + muted border, 26×26); state is shown by the glyph alone — accent-coloured **✓** when on, muted **○** when off (`data-checked` carries state, `aria-pressed` reflects it). Toggling fires `onEnabled()`; the Scheduler skips disabled modules whose `respectsEnabled()` returns true (default). -- **Emoji tags** next to the name show the same set the type picker uses for that type: role emoji + dimensional emoji (both derived in the UI from `role` and `dim`) + the curated `tags()` string from `/api/types`. Identical visual identity across card and picker. -- **Help link (?)** at the far right of the title row opens the module's spec page on GitHub in a new tab. The path comes from `docPath` in `/api/types` (engine-provided, relative to `docs/moonmodules/`); the link is omitted when the type declares no doc path. -- **Stats line** shows timing and memory: `🕒 <timing>` then `🧠 <static>[ + <dynamic>]`. Timing is fps or µs/ms per the global toggle (µs under 1 ms, ms above); it is omitted entirely when the module has no measured loop time. Memory is the module's C++ object size (`classSize`); the `+ <dynamic>` part (`dynamicBytes`, heap) is shown only when the module allocated heap. Sizes are compact-formatted (`B` / `KB`). Clicking the line cycles the timing figure fps ↔ ms; the memory figure is unaffected. Mode persists in `localStorage['mm_timing_mode']` and the toggle applies to all cards globally. -- **Reorder is drag-and-drop** (HTML5 DnD) on the whole card — works on desktop and mobile. A drag starting on an interactive control (slider, button, toggle, select, text input, help link) is canceled in `dragstart` so the control's own gesture is used instead; drags from any other part of the card start a reorder. A drag is only accepted when source and target share the same `.card-children` container — i.e. they are true siblings under one parent. -- **Replace (✎) / Delete (×) / drag-reorder** apply to *user-managed children* — any module whose role is one a container accepts (`acceptsChildRoles`) and that hasn't opted out via `userEditable: false`. Replace swaps for another type at the same position (type picker filtered to the module's own role; replacement starts at its own defaults; siblings/order/root preserved). Delete is a press-twice confirm (first click arms — red `✓` — second deletes; disarms after 3s or on pointer-leave; no browser `confirm()`). A child with `userEditable: false` (e.g. PreviewDriver, which feeds the live preview) shows none of these — it's fixed-shape like a code-wired child. -- **Drag handle (☰)** is a visual cue; the whole card is the draggable element. -- **`+ add child`** renders in a parent's footer when its type declares `acceptsChildRoles` (non-empty). Opens the [type picker](#type-picker) filtered to those roles. The button hides while the picker is open and reappears when it closes. - -## Control types - -Auto-rendered by `controls[].type`. Adding a new MoonModule with these control types requires no UI changes. - -| Type | Element | Interaction | Debounce | -|---|---|---|---| -| `slider` (uint8/uint16) | range + numeric display | drag → label updates instantly; value sent debounced; reset-to-default ↺ if a default is known | 150ms | -| `toggle` (bool) | switch (pill track + sliding thumb; visually-hidden `<input type=checkbox>` underneath stays the source of truth) | sends immediately on change | none | -| `select` | dropdown | sends immediately; server may rebuild controls (dynamic onBuildControls) | none | -| `text` | text input | sends debounced | 500ms | -| `password` | password input | masked; hold-to-peek button reveals the stored value | 500ms | -| `display` (read-only) | static text | WS push updates value in place | n/a | -| `display-int` (read-only int + unit) | formatted text (`-58 dBm`) | WS push updates value; the unit suffix is set device-side at `addReadOnlyInt` time and carried in the descriptor's `aux` slot, not in a per-control string buffer | n/a | -| `time` (read-only seconds) | formatted text (`1d 4h 27m 13s`) | WS push updates | n/a | -| `progress` | bar + numeric `X / max` | WS push updates value | n/a | -| `ipv4` | text input (dotted-quad) | sends on change; server validates (`parseDottedQuad`) and rejects malformed values with 400. 4-byte octet storage device-side instead of a 16-char string. | n/a | -| `button` | clickable button | sends value=1 on click | none | - -**Reset-to-default button (↺).** Appears next to controls whose default is known. Defaults are captured from a fresh probe instance per type (factory's probe — no per-control boilerplate) and emitted in `/api/types`. The button is dim/inactive when value equals default, bright/clickable otherwise. Clicking sends the default value. - -**Hidden controls.** When the engine returns `hidden: true` on a control descriptor, the UI skips rendering it. Used for conditional controls like NetworkModule's static-IP fields under DHCP. - -**Password controls are obfuscated, not encrypted.** A `password` control (`ControlType::Password`) is serialized in `/api/state` as `{"type":"password","value":"<encoded>"}` where `<encoded>` is the password XOR'd with a fixed key then base64-encoded. The UI decodes it (`decodePassword()` in `app.js`) so the input holds the real value — masked by the password field, revealed by hold-to-peek. This is a **first line of defence only**: the XOR key is a shared constant present in both the firmware and `app.js`, so the obfuscation is trivially reversible by anyone who looks. It stops the password being plainly readable in a raw `curl /api/state`; it is not real protection against a determined reader. Writes (`/api/control`) set the value like any text control. - -## Type picker - -The same picker serves two purposes: **add** (triggered by `+ add child`) and **replace** (triggered by the ✎ button on a card). Renders inline inside the card (not a modal). - -- **Role filter**: in add mode, filters to the parent's `acceptsChildRoles` (the device declares it per-type in `/api/types` — the UI hardcodes no container→role mapping). In replace mode, filters to the target module's own role. -- **Emoji tag chips**: a row of toggle chips above the list, one per distinct emoji across the role-filtered types. Each type's emoji set has three sources, in this order: a **role chip** (derived in the UI from `role`), a **dimensional chip** (derived in the UI from `dim` when the type declares one — 1/2/3 means 1D/2D/3D), and the curated **`tags`** string from `/api/types` (the module's `tags()` — a flash string literal). The UI treats `tags` as opaque: it splits the string into grapheme clusters and renders each as a chip. The domain that owns this UI assigns each emoji's meaning — see the domain's own architecture page for the assignments (e.g. [architecture.md § Web UI](../../../architecture.md#web-ui) for the role / dim / origin / creator / audio / moving-head assignments used by the light domain shipped today). Toggling chips narrows the list with **AND** logic: a type shows only if it carries every active chip. Each list row shows the type's emoji before its name. -- **Search box** with substring match on type name. Search and chips combine (both must match). -- **Keyboard nav**: type to filter, ↓ to enter list, ↑↓ to move, Enter to confirm, Esc to cancel. -- **Confirm / Cancel** action buttons at the bottom (the confirm button reads `create` or `replace` per mode). Double-click a row to confirm immediately. - -## Module hierarchy - -Each project pins a fixed top-level shape in its `main.cpp` — the side nav lists those roots in registration order, and the UI does **not** allow root reorder. System modules (Filesystem, System, Network, HttpServer) are always present; the domain modules (the actual data-flow pipeline) sit alongside them. For the light-domain shape see [architecture.md § Web UI](../../../architecture.md#web-ui). - -Child reorder *within* a parent (a child within a container) is supported via HTML5 drag-and-drop (desktop and mobile), which calls `POST /api/modules/<n>/move {to:N}`. - -## Communication - -### WebSocket (primary, for state updates) - -- URL: `ws://<host>/ws` (same port as HTTP) -- Server pushes full state snapshot as JSON ~1/sec (same shape as `GET /api/state`). The JSON is built through a streaming sink with no fixed-size buffer — a module tree of any size serializes without truncation -- Server may push **binary frames** on the same socket. The first byte selects the frame type and dispatches to a domain renderer; the rest of the frame is the domain's choice. The UI ignores types it doesn't recognise. See [Domain preview channel](#domain-preview-channel) for the dispatching contract and the domain's own architecture page for the payload (e.g. [architecture.md § Web UI](../../../architecture.md#web-ui)) -- Client sends `"ping"` every 25s as keepalive (Safari kills idle sockets otherwise) -- Auto-reconnect on close with exponential backoff (500ms → 5s ceiling) -- Pause on `document.visibilityState === 'hidden'`; resume on `pageshow` (Safari bfcache survival) - -### REST API (for mutations and initial state) - -The full endpoint list, request/response shapes, and field-level notes (including the `/api/types` fields the type picker reads and the `userEditable` flag the card actions read) live in **[HttpServerModule § REST API](HttpServerModule.md#rest-api)** — the server owns that contract. The UI consumes it as documented there; `/api/control` is `{module, control, value}`. `/api/state` streams with no fixed-size buffer, so a tree of any size serializes without truncation — the property the no-rebuild contract below relies on. - -### Static assets - -- `GET /` → `index.html` -- `GET /app.js`, `GET /style.css` -- `GET /moonlight-logo.png` → the MoonLight logo (`image/png`), used in the header and as the favicon -- `Cache-Control: no-cache` on all UI responses (live development) -- ESP32: served from C arrays in `ui_embedded.h` regenerated at build time -- Desktop: served from disk (`uiPath_`) - -## Styling - -- **Dark theme (default)**: background `#1a1a2e`, foreground `#e0e0e0`, accent `#a78bfa` (purple) -- **Light theme**: `[data-theme="light"]` on `<body>` swaps a curated set of overrides -- **System UI font stack** — `system-ui, sans-serif` — not monospace. Numbers use `font-variant-numeric: tabular-nums` so digits don't dance. -- **Module nesting** by progressively lighter card backgrounds (depth 0 / 1 / 2 each one step lighter) -- **Responsive breakpoint** at 820px (padding adjusts) -- **Color semantics** consistent across the app: - - Green (`#22c55e`/`#6ee7b7`) — connected, ok, pass, success - - Red (`#f87171`) — error, fail, crashed, delete - - Purple (`#a78bfa`/`#c4b5fd`) — accent, brand, active, value - - Gray (`#6b7280`/`#9ca3af`/`#4b5563`) — secondary text, muted - -## Domain preview channel - -The UI dedicates a binary slot on its WebSocket — separate from the JSON state updates — for a domain-specific preview frame. The engine pushes one frame per render; the UI hands it to a domain renderer. - -Generic shape: `[type-byte] [domain-specific header] [payload]`. The first byte identifies the frame type and selects the renderer the UI dispatches to; everything after is the domain's choice. - -- The canvas is sticky just below the status bar and scroll-shrinks 0→50% over 300px of page scroll -- `width: 100%` + `aspect-ratio: 1 / 1` derives the height from the column width; `max-height: 50vh` caps it so the canvas never dominates the viewport -- The last received frame is cached so camera gestures (orbit, pan, zoom) redraw instantly without waiting for a new frame -- `touch-action: none` so single- and multi-finger gestures don't trigger native page scroll or pinch-zoom -- WebGL clear color is `(0, 0, 0, 0)` — transparent canvas blends into either theme without per-frame color work - -For the light-domain renderer (WebGL point cloud, frame format, orbit camera, downsampling) see [architecture.md § Web UI](../../../architecture.md#web-ui). - -## State updates — the no-rebuild contract - -When a WS state-snapshot arrives: - -1. For each module in the payload: - - If `controls.deviceName` exists, update the status-bar device name and `document.title` - - For each `controls[key]`: - - Look up the matching `<input>`/`<span>`/`<progress>`/`<select>` by `[data-mid][data-key]` - - **Skip** the update if `Date.now() - dragTs[mid:key] < 1000` (user is actively interacting) - - Otherwise patch the value in place. Sliders update both the input and the adjacent `value-display` span. Toggles set `checked`. Selects set `value`. - - Update the reset-to-default button's "at default?" state. - - Update timing display (`fps` or `ms` per the global toggle). - -The DOM is **never rebuilt** during a state update. Full re-render only happens on (a) initial load and (b) after a mutation that changes the tree shape (`/api/control` for a Select that triggers `rebuildControls`, `/api/modules` add/delete, `/api/modules/<n>/move`, `/api/modules/<n>/replace`). - -**Object identity across WS pushes.** Every WS state push replaces `state` with a fresh JSON tree. Card-render closures that hold a `mod` reference become stale within ~1s. Any lookup of "which index am I now?" must use `findIndex(c => c.name === mod.name)` rather than `indexOf(mod)`. - -## localStorage keys - -```text -mm_selectedRoot id of currently-selected root module (string) -mm_theme "dark" | "light" (default: "dark") -mm_timing_mode "fps" | "ms" (default: "fps") -``` - -No other client state persists. Reorder, control values, etc. all live on the device. - -## Source - -The hand-maintained files: [index.html](../../../../src/ui/index.html) · [app.js](../../../../src/ui/app.js) · [style.css](../../../../src/ui/style.css) · [preview3d.js](../../../../src/ui/preview3d.js) · [install-picker.js](../../../../src/ui/install-picker.js). Embedded into the firmware at build time via [embed_ui.cmake](../../../../src/ui/embed_ui.cmake). diff --git a/docs/moonmodules/core/ui/ui.md b/docs/moonmodules/core/services.md similarity index 64% rename from docs/moonmodules/core/ui/ui.md rename to docs/moonmodules/core/services.md index 050a0d80..b62d38cc 100644 --- a/docs/moonmodules/core/ui/ui.md +++ b/docs/moonmodules/core/services.md @@ -1,23 +1,31 @@ -# Core UI modules +# Core services The user-facing core services — the machinery that runs a show, each configurable in the web UI. Every row links to its generated technical page (the full API, from the `.h`) and its tests. Cross-cutting rationale that no single `.h` owns lives in the prose sections below the table. +<a id="system"></a> + ### System The device's identity and vitals — name (behind mDNS `<name>.local`, the SoftAP SSID, the DHCP hostname), uptime, heap, and per-module footprint reporting. Hosts the Audio and I2C-scan peripherals. +<img src="../../../assets/core/SystemModule.png" width="300" alt="System module controls"> + - `deviceName` — the device identity behind mDNS `<name>.local`, the SoftAP SSID, and the DHCP hostname. - `deviceModel` — the board model (drives the installer catalog entry). - read-only vitals — `uptime`, `fps`, `heap`, `psram`, `flash`, `chip`, and per-module footprint. -Detail: [technical](../moxygen/SystemModule.md) +Detail: [technical](moxygen/SystemModule.md) -[Tests](../../../tests/unit-tests.md#systemmodule) +[Tests](../../tests/unit-tests.md#systemmodule) + +<a id="network"></a> ### Network WiFi / Ethernet connectivity, static-IP configuration, RSSI and TX-power reporting. Brings the device onto the LAN before the HTTP and WebSocket servers start. +<img src="../../../assets/core/NetworkModule.png" width="300" alt="Network module controls"> + - `mode` — WiFi / Ethernet / off. - `ssid` / `password` — WiFi credentials. - `mDNS` — the `<name>.local` hostname. @@ -25,122 +33,91 @@ WiFi / Ethernet connectivity, static-IP configuration, RSSI and TX-power reporti - `ethType` / `ethPhyAddr` / `ethRstGpio` / … — Ethernet PHY configuration. - read-only — `rssi` (dBm), `txPower` (dBm). -Detail: [technical](../moxygen/NetworkModule.md) +Detail: [technical](moxygen/NetworkModule.md) -[Tests](../../../tests/unit-tests.md#networkmodule) +[Tests](../../tests/unit-tests.md#networkmodule) + +<a id="improv-provisioning"></a> ### Improv provisioning Serial/BLE Improv Wi-Fi provisioning — the web installer hands credentials to a fresh device over this protocol during the flash-and-connect flow. +<img src="../../../assets/core/ImprovProvisioningModule.png" width="300" alt="Improv provisioning module controls"> + - `provision_status` — read-only provisioning state. -Detail: [technical](../moxygen/ImprovProvisioningModule.md) +Detail: [technical](moxygen/ImprovProvisioningModule.md) + +<a id="devices"></a> ### Devices Discovers and lists other projectMM devices on the LAN (the `devices` List control), each row expanding to a detail panel; persists the last-known list across reboot. +<img src="../../../assets/core/DevicesModule.png" width="300" alt="Devices module — discovered LAN devices"> + - `devices` — a List control of discovered devices; each row expands to a detail panel. Persistable. -Detail: [technical](../moxygen/DevicesModule.md) +Detail: [technical](moxygen/DevicesModule.md) -[Tests](../../../tests/unit-tests.md#devicesmodule) +[Tests](../../tests/unit-tests.md#devicesmodule) + +<a id="mqtt"></a> ### MQTT -Bridges the light to an MQTT broker so a home-automation hub (Homebridge) can control it. It drives the same `Drivers` controls IR and the WLED app do — every command routes through `Scheduler::setControl`, so MQTT is a transport, not new control logic. The MQTT 3.1.1 client is our own (dependency-free, golden-vector-tested in `MqttPacket.h`), speaking to the broker over the platform TCP socket. Disabled until a broker is set; works over WiFi or Ethernet. +Bridges the light to an MQTT broker so a home-automation hub (Homebridge) can control it — a transport over the shared `Scheduler::setControl` apply-core, not new control logic. Our own dependency-free MQTT 3.1.1 client; disabled until a broker is set. Topics, colour-wheel mapping, and the Homebridge config: ⌄ details. + +<img src="../../../assets/core/MqttModule.png" width="300" alt="MQTT module controls"> - `broker` — the broker hostname (e.g. `homeassistant.lan`) or IP. A hostname is resolved via DNS. - `port` — broker port (default 1883). - `username` / `password` — broker credentials (optional; the password is stored obfuscated like the WiFi password). -- The topic prefix is `projectMM/<mac>` — a **stable** identifier (the last 6 hex of the device's MAC), fixed for the device's life. Renaming the device does **not** change its topics, so a hub's config never breaks on a rename (the WLED/Tasmota/Home-Assistant convention). Not a stored control. - read-only — `mqtt_status` (`disabled` / `idle` / `connecting` / `connected` / `disconnected` / an error). -**Topics** (for a device whose MAC ends `563cfe`): the device SUBSCRIBEs to the `set` topics and PUBLISHes the `get` topics on change (and on connect, so a controller never reads "No Response"). It also publishes its friendly `deviceName` on the retained `name` topic, so a hub can show the human name while the topics stay MAC-stable: - -| direction | topic | payload | -|---|---|---| -| set → device | `projectMM/563cfe/on/set` | `true` / `false` | -| device → get | `projectMM/563cfe/on/get` | `true` / `false` | -| set → device | `projectMM/563cfe/brightness/set` | `0`–`100` | -| device → get | `projectMM/563cfe/brightness/get` | `0`–`100` | -| set → device | `projectMM/563cfe/hsv/set` | `h,s,v` (hue `0`–`359`, sat/val `0`–`100`) | -| device → get | `projectMM/563cfe/hsv/get` | `h,s,v` | -| device → get | `projectMM/563cfe/name` | the friendly `deviceName` (retained) | - -The HomeKit colour wheel has no "palette" concept, so `hsv/set`'s hue+saturation pick the **nearest palette** (each built-in palette has a representative colour; the closest one is selected) and the value drives brightness — the colour wheel becomes a natural palette selector. - -**Homebridge** — install [`homebridge-mqttthing`](https://github.com/arachnetech/homebridge-mqttthing) and add a `lightbulb` accessory. Use the device's own MAC suffix (read it from the `mqtt_status`/topics, or `mosquitto_sub -t 'projectMM/#'`) in place of `563cfe`: - -```json -{ - "accessory": "mqttthing", - "type": "lightbulb", - "name": "projectMM", - "url": "mqtt://<broker>:1883", - "username": "<user>", - "password": "<pass>", - "topics": { - "getOn": "projectMM/563cfe/on/get", - "setOn": "projectMM/563cfe/on/set", - "getBrightness": "projectMM/563cfe/brightness/get", - "setBrightness": "projectMM/563cfe/brightness/set", - "getHSV": "projectMM/563cfe/hsv/get", - "setHSV": "projectMM/563cfe/hsv/set" - }, - "onValue": "true", - "offValue": "false" -} -``` - -Home Assistant does not need MQTT: it adopts the device through its built-in WLED integration over the existing WLED `/json` API. +Detail: [technical](moxygen/MqttModule.md) -Detail: [technical](../moxygen/MqttModule.md) +[Tests](../../tests/unit-tests.md#mqttmodule) -[Tests](../../../tests/unit-tests.md#mqttmodule) +<a id="firmware-update"></a> ### Firmware update Over-the-air firmware flashing — the one operation that swaps the binary and needs a power cycle (every *config* change applies live; a firmware OTA does not). +<img src="../../../assets/core/FirmwareUpdateModule.png" width="300" alt="Firmware update module controls"> + - `firmware` — the OTA image to flash. - read-only — `version`, `build`, `firmwarePartition`, `update_pct` (progress). -Detail: [technical](../moxygen/FirmwareUpdateModule.md) - -[Tests](../../../tests/unit-tests.md#firmwareupdatemodule) - -### Filesystem +Detail: [technical](moxygen/FirmwareUpdateModule.md) -Persists control values as JSON and restores them on boot, overlaying loaded values through each control's pointer during `onBuildControls()`. The home of the no-reboot live-reconfiguration behaviour. +[Tests](../../tests/unit-tests.md#firmwareupdatemodule) -- read-only — `lastSaved`. - -Detail: [technical](../moxygen/FilesystemModule.md) - -[Tests](../../../tests/unit-tests.md#filesystemmodule) +<a id="file-manager"></a> ### File Manager -A boot-wired system tool (distinct from Filesystem, which is the persistence *engine*): browse and manage the device filesystem. It renders a dedicated panel — a lazy expand/collapse folder **tree** (the standard VS Code / Explorer shape: each folder loads its children on first expand) plus an inline text editor. Browsing is UI-side over the `/api/dir` listing endpoint; file contents come over `/api/file`; so the module itself stays minimal, exposing only the operations. Dot-prefixed entries (the `.config` persistence dir) are hidden unless `show hidden` is on. +A boot-wired system tool (distinct from Filesystem, the persistence *engine*): browse and manage the device filesystem from a dedicated panel — a lazy expand/collapse folder tree (VS Code / Explorer shape) plus an inline text editor. Browsing is UI-side over `/api/dir` + `/api/file`, so the module itself stays minimal. Tree/toolbar/editor behaviour: ⌄ details. +<img src="../../../assets/core/FileManagerModule.png" width="300" alt="File Manager panel — folder tree + toolbar"> + +- `file browser` — the panel itself: an expand/collapse folder tree with a toolbar (+folder / +file / delete / refresh / upload) and an inline text editor. The module's main surface (⌄ details for the interactions). - `show hidden` — reveal dot-prefixed files/folders (e.g. `.config`); forwarded to `/api/dir` as its `hidden` filter. -- Click a folder's row to select it and toggle its expansion (▸/▾); click a selected file to open the editor. -- The toolbar acts on the selected node: **+ folder** creates a folder inside it, **+ file** creates an empty file (click it to edit), **🗑 delete** removes the selected file or empty folder (press-twice to confirm), **⟳** refreshes. -- **Drag files from the desktop** onto a folder (or the tree) to upload them — the body streams straight to the file (any size, binary-safe; capped only by a sanity limit and the free space, which it reports if short); a per-file **⤓** streams it back to the desktop. -- The editor loads a file's text, pretty-prints JSON on open, and saves atomically; a binary file (contains a NUL) loads read-only (use ⤓ to fetch it intact). Upload and download both stream, so neither truncates. -- A filesystem-usage bar (used / total, read from the platform) sits below the tree, with a `last saved` line under it (how long ago config was persisted — read from the FilesystemModule engine). -- Create / delete are HTTP calls (`POST` / `DELETE /api/dir?path=`), not controls — the path rides the request, so nothing is stored on the device per op. +- `filesystem` — read-only usage bar (used / total bytes, from the platform). +- `lastSaved` — read-only; how long ago config was persisted (read from the Filesystem engine). -Last-modified dates (needs an NTP time source + LittleFS mtime), binary/large + folder upload, folder-as-zip download, and `.ml` syntax highlighting are backlogged ([backlog-core § File Manager follow-ups](../../../backlog/backlog-core.md#file-manager-follow-ups)). +Detail: [technical](moxygen/FileManagerModule.md) -Detail: [technical](../moxygen/FileManagerModule.md) +<a id="audio"></a> ### Audio A System peripheral (added by the user, not auto-wired): an I²S microphone (or line-in ADC) feeding the FFT that audio-reactive effects consume via `AudioModule::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. +<img src="../../../assets/core/AudioModule.png" width="300" alt="Audio module controls"> + - `wsPin` / `sdPin` / `sckPin` — the I²S GPIOs (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. @@ -149,30 +126,95 @@ A System peripheral (added by the user, not auto-wired): an I²S microphone (or - `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`. -Detail: [technical](../moxygen/AudioModule.md) +Detail: [technical](moxygen/AudioModule.md) -[Tests](../../../tests/unit-tests.md#audiomodule) +[Tests](../../tests/unit-tests.md#audiomodule) + +<a id="i2c-scan"></a> ### I2C scan A System peripheral that probes the I²C bus (default GPIO21/22) on a button press and reports the addresses found. +<img src="../../../assets/core/I2cScanModule.png" width="300" alt="I2C scan module controls"> + - `sda` / `scl` — the bus GPIOs (default GPIO21/22). - `scan` — a button; press to probe the bus now. - read-only — `result` (addresses found). -Detail: [technical](../moxygen/I2cScanModule.md) +Detail: [technical](moxygen/I2cScanModule.md) + +<a id="ir"></a> ### IR -A System peripheral (added per board, not auto-wired): an IR remote receiver that drives other modules' controls through the shared `Scheduler::setControl` primitive — the device's IR input, the counterpart of the WLED-app bridge. It **learns** any remote: pick an action in `learn`, press a remote button to bind its code, and thereafter that button drives the action. Decoding is real NEC-over-RMT; a received code (bound or not) shows in the status line. +A System peripheral (added per board): an IR remote receiver that drives other modules' controls through the shared `Scheduler::setControl` primitive. It **learns** any remote (NEC-over-RMT): pick an action in `learn`, press a button to bind its code. What each action does + the status-line messages: ⌄ details. -<img src="../../../assets/core/IrModule.jpeg" width="300" alt="An IR receiver and the common 21-key RGB remote it decodes"> +<img src="../../../assets/core/IrModule.png" width="300" alt="IR module controls"> - `pin` — the IR receiver GPIO (unset until entered; on the SE16 it shares GPIO 5 with the Ethernet MISO via the board switch, on the LightCrafter it is its own GPIO 4 alongside Ethernet). - `learn` — pick an action to bind (`on/off` / brightness up / brightness down / palette next / palette prev); the next received code binds to it, then learning disarms. The first option, `off`, is the disarmed state (bind nothing), not a light action. - `code on/off` / `code brightness up` / `code brightness down` / `code palette next` / `code palette prev` — read-only, the learned code for each action (persisted). -The actions toggle `Drivers.on` (master power), nudge `Drivers.brightness` (±16, clamped 0–255) and step `Drivers.palette` (next/previous). The status line reports setup state ("set pin to receive" / "ready"), the learn prompt, a binding ("learned … = 0x…"), a fired action ("Drivers.brightness → N", "Drivers.on → off"), and an unbound code ("received 0x… (unassigned)"). +Detail: [technical](moxygen/IrModule.md) + +## MQTT — details + +The topic prefix is `projectMM/<mac>` — a **stable** identifier (the last 6 hex of the device's MAC), fixed for the device's life. Renaming the device does **not** change its topics, so a hub's config never breaks on a rename (the WLED/Tasmota/Home-Assistant convention). It's derived, not a stored control. + +**Topics** (for a device whose MAC ends `563cfe`): the device SUBSCRIBEs to the `set` topics and PUBLISHes the `get` topics on change (and on connect, so a controller never reads "No Response"). It also publishes its friendly `deviceName` on the retained `name` topic, so a hub can show the human name while the topics stay MAC-stable: + +| direction | topic | payload | +|---|---|---| +| set → device | `projectMM/563cfe/on/set` | `true` / `false` | +| device → get | `projectMM/563cfe/on/get` | `true` / `false` | +| set → device | `projectMM/563cfe/brightness/set` | `0`–`100` | +| device → get | `projectMM/563cfe/brightness/get` | `0`–`100` | +| set → device | `projectMM/563cfe/hsv/set` | `h,s,v` (hue `0`–`359`, sat/val `0`–`100`) | +| device → get | `projectMM/563cfe/hsv/get` | `h,s,v` | +| device → get | `projectMM/563cfe/name` | the friendly `deviceName` (retained) | + +The HomeKit colour wheel has no "palette" concept, so `hsv/set`'s hue+saturation pick the **nearest palette** (each built-in palette has a representative colour; the closest one is selected) and the value drives brightness — the colour wheel becomes a natural palette selector. + +**Homebridge** — install [`homebridge-mqttthing`](https://github.com/arachnetech/homebridge-mqttthing) and add a `lightbulb` accessory. Use the device's own MAC suffix (read it from the `mqtt_status`/topics, or `mosquitto_sub -t 'projectMM/#'`) in place of `563cfe`: + +```json +{ + "accessory": "mqttthing", + "type": "lightbulb", + "name": "projectMM", + "url": "mqtt://<broker>:1883", + "username": "<user>", + "password": "<pass>", + "topics": { + "getOn": "projectMM/563cfe/on/get", + "setOn": "projectMM/563cfe/on/set", + "getBrightness": "projectMM/563cfe/brightness/get", + "setBrightness": "projectMM/563cfe/brightness/set", + "getHSV": "projectMM/563cfe/hsv/get", + "setHSV": "projectMM/563cfe/hsv/set" + }, + "onValue": "true", + "offValue": "false" +} +``` + +Home Assistant does not need MQTT: it adopts the device through its built-in WLED integration over the existing WLED `/json` API. + +## File Manager — details + +The panel is a lazy folder **tree** (each folder loads its children on first expand) plus an inline text editor. Dot-prefixed entries (the `.config` persistence dir) are hidden unless `show hidden` is on. + +- Click a folder's row to select it and toggle its expansion (▸/▾); click a selected file to open the editor. +- The toolbar acts on the selected node: **+ folder** creates a folder inside it, **+ file** creates an empty file (click it to edit), **🗑 delete** removes the selected file or empty folder (press-twice to confirm), **⟳** refreshes. +- **Drag files from the desktop** onto a folder (or the tree) to upload them — the body streams straight to the file (any size, binary-safe; capped only by a sanity limit and the free space, which it reports if short); a per-file **⤓** streams it back to the desktop. +- The editor loads a file's text, pretty-prints JSON on open, and saves atomically; a binary file (contains a NUL) loads read-only (use ⤓ to fetch it intact). Upload and download both stream, so neither truncates. +- Create / delete are HTTP calls (`POST` / `DELETE /api/dir?path=`), not controls — the path rides the request, so nothing is stored on the device per op. + +Last-modified dates (needs an NTP time source + LittleFS mtime), binary/large + folder upload, folder-as-zip download, and `.ml` syntax highlighting are backlogged ([backlog-core § File Manager follow-ups](../../backlog/backlog-core.md#file-manager-follow-ups)). + +## IR — details + +The learned actions drive the `Drivers` module: `on/off` toggles `Drivers.on` (master power), brightness up/down nudge `Drivers.brightness` (±16, clamped 0–255), and palette next/prev step `Drivers.palette`. -Detail: [technical](../moxygen/IrModule.md) +The status line reports setup state ("set pin to receive" / "ready"), the learn prompt, a binding ("learned … = 0x…"), a fired action ("Drivers.brightness → N", "Drivers.on → off"), and an unbound code ("received 0x… (unassigned)"). diff --git a/docs/moonmodules/core/supporting.md b/docs/moonmodules/core/supporting.md new file mode 100644 index 00000000..0263f15a --- /dev/null +++ b/docs/moonmodules/core/supporting.md @@ -0,0 +1,41 @@ +# Core supporting modules + +The core machinery the UI modules lean on — not directly user-facing, so no controls of their own. Each row links to its generated technical page (the full API, from the `.h`) and its tests. Cross-file design rationale that no single `.h` owns lives in the prose sections below the table. + +### Control + +A named, typed value a MoonModule exposes to the UI — the binding between a class variable and its web-UI widget, DMX channel, and persisted value. Every module's `controls_` is a list of these. + +Detail: [technical](moxygen/Control.md) + +[Tests](../../tests/unit-tests.md#moonmodule) + +### Scheduler + +Orders module `setup()` by declared init-order dependencies (WiFi before HTTP, HTTP before WebSocket) and drives the per-tick `loop()` sweep. The one place init-order lives, so modules declare a dependency instead of hard-coding boot sequence. + +Detail: [technical](moxygen/Scheduler.md) + +[Tests](../../tests/unit-tests.md#scheduler) + +### MoonModule + +The base class every module derives from — the shared lifecycle (`setup` / `loop` / `teardown`), the `controls_` list, child propagation, and the self-reporting footprint (`classSize` / `dynamicBytes` / `loopTimeUs`). Learn the pattern once, apply it everywhere. + +Detail: [technical](moxygen/MoonModule.md) + +[Tests](../../tests/unit-tests.md#moonmodule) + +<a id="filesystem"></a> + +### Filesystem + +The persistence **engine**: writes control values to `/.config/*.json` and restores them on boot, overlaying loaded values through each control's pointer during `onBuildControls()`. A non-UI module (renders no card); its "last saved" status is surfaced by the File Manager. The home of the no-reboot live-reconfiguration behaviour (see below). + +Detail: [technical](moxygen/FilesystemModule.md) + +[Tests](../../tests/unit-tests.md#filesystemmodule) + +## Persistence and dynamic rebuild + +Control values persist via [FilesystemModule](moxygen/FilesystemModule.md), which overlays loaded values through each control's variable pointer during `onBuildControls()`. Calling `onBuildControls()` again at runtime (e.g. when a Select changes mode) clears and rebuilds the set, so only the controls relevant to the current mode show — this is how a control's conditional `hidden` flag re-evaluates. The rebuild sweep is also how a config change applies live, with no reboot. diff --git a/docs/moonmodules/core/supporting/supporting.md b/docs/moonmodules/core/supporting/supporting.md deleted file mode 100644 index cf5eb446..00000000 --- a/docs/moonmodules/core/supporting/supporting.md +++ /dev/null @@ -1,31 +0,0 @@ -# Core supporting modules - -The core machinery the UI modules lean on — not directly user-facing, so no controls of their own. Each row links to its generated technical page (the full API, from the `.h`) and its tests. Cross-file design rationale that no single `.h` owns lives in the prose sections below the table. - -### Control - -A named, typed value a MoonModule exposes to the UI — the binding between a class variable and its web-UI widget, DMX channel, and persisted value. Every module's `controls_` is a list of these. - -Detail: [technical](../moxygen/Control.md) - -[Tests](../../../tests/unit-tests.md#moonmodule) - -### Scheduler - -Orders module `setup()` by declared init-order dependencies (WiFi before HTTP, HTTP before WebSocket) and drives the per-tick `loop()` sweep. The one place init-order lives, so modules declare a dependency instead of hard-coding boot sequence. - -Detail: [technical](../moxygen/Scheduler.md) - -[Tests](../../../tests/unit-tests.md#scheduler) - -### MoonModule - -The base class every module derives from — the shared lifecycle (`setup` / `loop` / `teardown`), the `controls_` list, child propagation, and the self-reporting footprint (`classSize` / `dynamicBytes` / `loopTimeUs`). Learn the pattern once, apply it everywhere. - -Detail: [technical](../moxygen/MoonModule.md) - -[Tests](../../../tests/unit-tests.md#moonmodule) - -## Persistence and dynamic rebuild - -Control values persist via [FilesystemModule](../moxygen/FilesystemModule.md), which overlays loaded values through each control's variable pointer during `onBuildControls()`. Calling `onBuildControls()` again at runtime (e.g. when a Select changes mode) clears and rebuilds the set, so only the controls relevant to the current mode show — this is how a control's conditional `hidden` flag re-evaluates. The rebuild sweep is also how a config change applies live, with no reboot. diff --git a/docs/moonmodules/core/ui.md b/docs/moonmodules/core/ui.md new file mode 100644 index 00000000..d7a3c401 --- /dev/null +++ b/docs/moonmodules/core/ui.md @@ -0,0 +1,254 @@ +# Web UI + +The projectMM web UI as shipped — the render layer over the MoonModule tree. This page is the UI's +own implementation spec (status bar, cards, control rendering, styling, the no-rebuild update +contract). The **high-level architecture** — hand-maintained files, MoonModule-driven rendering, the +light-domain plug-in points — lives in [architecture.md § Web UI](../../architecture.md#web-ui); +the **backend contract** it consumes (every `/api/*` endpoint, the `/ws` frame shape, the control +descriptors) is owned by [HttpServerModule](moxygen/HttpServerModule.md); the **emoji legend** the +cards and picker render is [architecture.md § Tag emoji legend](../../architecture.md#tag-emoji-legend). +This page covers only what those don't: the browser-side rendering behaviour. + +## Interaction principles + +- **Two timescales for inputs.** Local UI feedback (a slider's value label, a toggle's state) + updates within ~20 ms of the input event; network sends are debounced (150 ms slider, 500 ms text), + so a slider feels instant even when the server is busy. +- **No write-back races.** A `dragTs[moduleId:key]` cooldown of 1 s stops an incoming WS push from + overwriting a control the user is actively manipulating. +- **No DOM rebuilds on state updates.** The initial render builds the tree; state updates patch values + in place via `querySelector` by `data-mid` / `data-key` (see [§ no-rebuild contract](#state-updates-the-no-rebuild-contract)). + +## Layout + +```text +┌─────────────────────────────────────────────────────────────┐ +│ Status bar: ☰ · logo · brand · device name · sys stats │ +│ · WS dot · reconnect · reboot · theme toggle │ +├──────────────┬──────────────────────────────────────────────┤ +│ Side nav │ Sticky preview canvas (shrinks on scroll) │ +│ (root list) ├──────────────────────────────────────────────┤ +│ │ Cards for the selected root module │ +│ │ ↳ child cards inline │ +│ ───────── │ ↳ grandchild cards inline │ +│ footer: │ │ +│ social © │ │ +└──────────────┴──────────────────────────────────────────────┘ +``` + +- Fixed status bar at top (44 px) — hamburger, MoonLight logo, then the rest. +- Side nav: a left column listing root modules; the selected root's card subtree fills the main area + (one root visible at a time). +- Module-card column max-width 500 px, centered (single-column, easier to read on phones). The preview + canvas is **not** capped — it spans the full content width. +- Preview canvas is sticky just below the status bar and shrinks 0 % → 50 % as the user scrolls + 0 → 300 px. + +## Status bar + +Fixed top, 44 px, left-to-right: + +1. **Hamburger (☰)** — toggles the side nav (`body.nav-open`). See [§ Side navigation](#side-navigation). +2. **MoonLight logo** — 28 px PNG from `/moonlight-logo.png`; the same image is the page favicon. +3. **Brand wordmark** — "projectMM". +4. **Device name** — from the `System.deviceName` control. +5. **System stats** — `uptime · NN K free` (uptime `Xd Yh Zm Ws`, free heap KB), read from the + SystemModule controls in the WS state push; no separate polling endpoint. +6. **WebSocket dot** — green = connected, gray = disconnected. The socket auto-reconnects with + exponential backoff (500 ms → 5 s) on close; no manual reconnect button (a page reload covers the + rare half-dead socket). +7. **Reboot button (⏻)** — red border via `data-crashed="true"` when `SystemModule.bootReason` is + PANIC / INT_WDT / TASK_WDT / BROWNOUT. Press-twice to confirm: the first click arms it (solid red), + a second sends `POST /api/reboot`. Disarms after 3 s or on pointer-leave — no `confirm()` popup. +8. **Theme toggle (☀/🌙)** — flips `[data-theme]` on `<body>`; preference in `localStorage['mm_theme']`. + +## Side navigation + +A left column listing the root modules, one entry per top-level MoonModule. + +- **One root visible at a time.** Clicking a nav entry sets the selected root; `renderCards()` renders + only that root's card subtree. The selection persists in `localStorage['mm_selectedRoot']`; the + active entry is highlighted. +- **Hamburger toggle.** ☰ toggles `body.nav-open`. On wide screens (≥ 820 px) the nav is a static + column the hamburger collapses/expands; on narrow screens (< 820 px) it's a slide-in drawer over a + dimming overlay (click the overlay or press Esc to close). +- **No root reorder.** Root order is fixed in `main.cpp`; the nav does not drag-reorder. +- **Footer** pinned to the bottom of the nav: social links (GitHub, Discord, Reddit, YouTube — inline + SVG) and a `© <year> MoonLight` line. + +The WS state push carries the full module tree; only the selected root is rendered, and +`updateValues()` patches just the visible cards (non-rendered roots have no DOM nodes, so their data +is silently ignored). Per-root server-side filtering was evaluated and deferred — the 1 Hz push is +cheap and the JSON is built through a streaming sink, so tree size is not a buffer-limit concern. + +## Module card + +Each MoonModule renders as a card, with **child cards nested inside their parent card's box** — the +parent's border encloses its children, so the tree shape is visible structurally, not just by +indentation. Nesting depth shows as progressively lighter backgrounds and a left-margin indent. + +```text +┌─ card ──────────────────────────────────┐ +│ [name] [emoji] [timing · 🧠 mem] [enabled toggle] [✎ × ☰] [? help] │ +│ [control rows — one per control] │ +│ ┌─ child card ────────────────────────┐ │ +│ │ … │ │ +│ └─────────────────────────────────────┘ │ +│ [+ add child] │ +└─────────────────────────────────────────┘ +``` + +- The parent's own controls render **above** its children; `+ add child` renders **below** them. + Child cards live in a `.card-children` wrapper appended into the parent card's DOM node (not flat + siblings); `renderModuleTree` recurses into the parent card, not into `main`. +- **Enabled toggle** in the right-hand action cluster mirrors `MoonModule::enabled()` — a styled + `<button>` (transparent + muted border, 26×26); state shown by the glyph alone (accent **✓** on, + muted **○** off; `data-checked` + `aria-pressed`). Toggling fires `onEnabled()`; the Scheduler skips + disabled modules whose `respectsEnabled()` returns true (default). +- **Emoji tags** next to the name show the same set the type picker uses: role + dimensional (both + UI-derived) + the curated `tags()` from `/api/types`. Identical identity across card and picker; the + assignments are [architecture.md § Tag emoji legend](../../architecture.md#tag-emoji-legend). +- **Help link (?)** at the far right of the title row opens the module's doc page in a new tab. The + path comes from `docPath` in `/api/types` (engine-provided, relative to `docs/moonmodules/`); omitted + when the type declares none. +- **Stats line** — `🕒 <timing>` then `🧠 <static>[ + <dynamic>]`. Timing is fps or µs/ms per the + global toggle (µs under 1 ms, ms above), omitted when the module has no measured loop time. Memory is + the C++ object size (`classSize`); the `+ <dynamic>` part (`dynamicBytes`, heap) shows only when the + module allocated heap. Clicking cycles the timing figure fps ↔ ms (persists in + `localStorage['mm_timing_mode']`, applies to all cards). +- **Reorder is drag-and-drop** (HTML5 DnD) on the whole card, desktop and mobile. A drag starting on + an interactive control is canceled in `dragstart` so the control's own gesture wins; a drag is + accepted only when source and target share the same `.card-children` container (true siblings). +- **Replace (✎) / Delete (×) / drag-reorder** apply to *user-managed children* — a module whose role a + container accepts (`acceptsChildRoles`) and that hasn't opted out via `userEditable: false`. Replace + swaps for another type at the same position (picker filtered to the module's role; replacement starts + at its own defaults). Delete is press-twice confirm. A `userEditable: false` child (e.g. + PreviewDriver) shows none of these — it's fixed-shape like a code-wired child. +- **`+ add child`** renders in a parent's footer when its type declares a non-empty + `acceptsChildRoles`; it opens the [type picker](#type-picker) filtered to those roles. + +## Control types + +Auto-rendered by `controls[].type` — adding a MoonModule with these types needs no UI change. (The +descriptors themselves, and their storage shape, are [Control](moxygen/Control.md) / +[coding-standards § store values in their native shape](../../coding-standards.md); this table is +how each *renders*.) + +| Type | Element | Interaction | Debounce | +|---|---|---|---| +| `slider` (uint8/uint16) | range + numeric display | drag → label instant; value sent debounced; reset-to-default ↺ | 150 ms | +| `toggle` (bool) | switch (pill + thumb; hidden `<input type=checkbox>` is the source of truth) | sends on change | none | +| `select` | dropdown | sends immediately; server may rebuild controls (dynamic `onBuildControls`) | none | +| `text` | text input | sends debounced | 500 ms | +| `password` | password input | masked; hold-to-peek reveals the stored value | 500 ms | +| `display` (read-only) | static text | WS push updates in place | n/a | +| `display-int` (read-only int + unit) | formatted text (`-58 dBm`) | unit suffix set device-side at `addReadOnlyInt` time, carried in the descriptor's `aux` slot | n/a | +| `time` (read-only seconds) | formatted text (`1d 4h 27m 13s`) | WS push updates | n/a | +| `progress` | bar + numeric `X / max` | WS push updates | n/a | +| `ipv4` | text input (dotted-quad) | server validates (`parseDottedQuad`), 400 on malformed; stored as 4 octets device-side | n/a | +| `button` | clickable button | sends value = 1 on click | none | + +- **Reset-to-default (↺)** appears next to controls whose default is known (captured from a fresh + probe instance per type, emitted in `/api/types`); dim when value == default, clicking sends the + default. +- **Hidden controls.** `hidden: true` on a descriptor → the UI skips rendering it (conditional + controls like NetworkModule's static-IP fields under DHCP). +- **Password controls are obfuscated, not encrypted.** A `password` control serializes in `/api/state` + as `{"type":"password","value":"<encoded>"}` where `<encoded>` is the password XOR'd with a fixed key + then base64-encoded; the UI decodes it (`decodePassword()`) so the input holds the real value (masked, + hold-to-peek). A **first line of defence only**: the XOR key is a shared constant in both the firmware + and `app.js`, so it's trivially reversible — it stops the password being plainly readable in a raw + `curl /api/state`, not a determined reader. + +## Type picker + +One picker serves **add** (`+ add child`) and **replace** (the ✎ button), rendered inline in the card +(not a modal). + +- **Role filter.** Add mode filters to the parent's `acceptsChildRoles` (declared per-type in + `/api/types` — the UI hardcodes no container→role map); replace mode filters to the target's own role. +- **Emoji tag chips.** A row of toggle chips above the list, one per distinct emoji across the filtered + types (sources + meanings: [architecture.md § Tag emoji legend](../../architecture.md#tag-emoji-legend)). + The UI treats `tags` as opaque — splits it into grapheme clusters, one chip each. Toggling narrows + with **AND** logic (a type shows only if it carries every active chip). +- **Search box** — substring match on type name; combines with chips (both must match). +- **Keyboard nav** — type to filter, ↓ to enter list, ↑↓ to move, Enter to confirm, Esc to cancel. +- **Confirm / Cancel** at the bottom (confirm reads `create` or `replace`); double-click a row to + confirm immediately. + +## Styling + +- **Dark theme (default):** background `#1a1a2e`, foreground `#e0e0e0`, accent `#a78bfa` (purple). +- **Light theme:** `[data-theme="light"]` on `<body>` swaps a curated set of overrides. +- **System UI font stack** (`system-ui, sans-serif`, not monospace); numbers use + `font-variant-numeric: tabular-nums` so digits don't dance. +- **Module nesting** by progressively lighter card backgrounds (depth 0 / 1 / 2). +- **Responsive breakpoint** at 820 px. +- **Colour semantics** consistent across the app: green (`#22c55e`/`#6ee7b7`) = connected/ok/pass; red + (`#f87171`) = error/fail/crashed/delete; purple (`#a78bfa`/`#c4b5fd`) = accent/brand/active/value; + gray (`#6b7280`/`#9ca3af`/`#4b5563`) = secondary/muted. + +## Domain preview channel + +The UI dedicates a binary slot on its WebSocket — separate from the JSON state updates — for a +domain-specific preview frame; the engine pushes one per render and the UI hands it to a domain +renderer. Generic shape: `[type-byte] [domain header] [payload]` — the first byte selects the +renderer, everything after is the domain's choice; the UI ignores types it doesn't recognise. + +- The canvas is sticky just below the status bar, scroll-shrinks 0→50 % over 300 px. +- `width: 100%` + `aspect-ratio: 1 / 1` derives the height from the column width; `max-height: 50vh` + caps it. +- The last received frame is cached so camera gestures (orbit/pan/zoom) redraw instantly without a new + frame; `touch-action: none` keeps gestures off native scroll/zoom. +- WebGL clear color `(0, 0, 0, 0)` — a transparent canvas blends into either theme with no per-frame + color work. + +The light-domain renderer (WebGL point cloud, the `0x03`/`0x02` frame format, orbit camera, +downsampling) is [architecture.md § Web UI](../../architecture.md#web-ui) / +[PreviewDriver](../light/moxygen/PreviewDriver.md). + +## State updates — the no-rebuild contract + +When a WS state snapshot arrives, for each module in the payload: +- If `controls.deviceName` exists, update the status-bar device name and `document.title`. +- For each `controls[key]`: look up the matching `<input>`/`<span>`/`<progress>`/`<select>` by + `[data-mid][data-key]`; **skip** if `Date.now() - dragTs[mid:key] < 1000` (user interacting); + otherwise patch the value in place (sliders update input + `value-display`; toggles set `checked`; + selects set `value`). Then update the reset-to-default "at default?" state and the timing display. + +The DOM is **never rebuilt** during a state update. A full re-render happens only on (a) initial load +and (b) a mutation that changes the tree shape (`/api/control` for a Select that triggers +`rebuildControls`; `/api/modules` add/delete/move/replace). + +**Object identity across pushes.** Every WS push replaces `state` with a fresh JSON tree, so a +card-render closure holding a `mod` reference goes stale within ~1 s. Any "which index am I now?" +lookup must use `findIndex(c => c.name === mod.name)`, not `indexOf(mod)`. + +## Communication client behaviour + +The endpoints, the `/ws` frame shape, and the streaming state sink are owned by +[HttpServerModule](moxygen/HttpServerModule.md); `/api/control` is `{module, control, value}`. The +**browser-side** socket behaviour the server doesn't dictate: + +- URL `ws://<host>/ws` (same port as HTTP); the server pushes a full-state snapshot ~1/s. +- Client sends `"ping"` every 25 s as keepalive (Safari kills idle sockets otherwise). +- Auto-reconnect on close with exponential backoff (500 ms → 5 s ceiling). +- Pause on `document.visibilityState === 'hidden'`, resume on `pageshow` (Safari bfcache survival). + +## localStorage keys + +```text +mm_selectedRoot id of the currently-selected root module (string) +mm_theme "dark" | "light" (default: "dark") +mm_timing_mode "fps" | "ms" (default: "fps") +``` + +No other client state persists; reorder, control values, etc. all live on the device. + +## Source + +Hand-maintained files: [index.html](../../../../src/ui/index.html) · +[app.js](../../../../src/ui/app.js) · [style.css](../../../../src/ui/style.css) · +[preview3d.js](../../../../src/ui/preview3d.js) · +[install-picker.js](../../../../src/ui/install-picker.js). Embedded into firmware at build time via +[embed_ui.cmake](../../../../src/ui/embed_ui.cmake). diff --git a/docs/moonmodules/light/moonlive/MoonLiveEffect.md b/docs/moonmodules/light/MoonLiveEffect.md similarity index 74% rename from docs/moonmodules/light/moonlive/MoonLiveEffect.md rename to docs/moonmodules/light/MoonLiveEffect.md index 2d932383..66eae210 100644 --- a/docs/moonmodules/light/moonlive/MoonLiveEffect.md +++ b/docs/moonmodules/light/MoonLiveEffect.md @@ -1,6 +1,6 @@ # MoonLive -MoonLive is projectMM's **live-script engine** — author an effect as text and run it on a running device, compiled to native machine code so it executes at near-hand-written speed in the render hot path. The broader design lives in [livescripts-analysis-top-down.md](../../../backlog/livescripts-analysis-top-down.md) (a backlog design study); this page documents the module. +MoonLive is projectMM's **live-script engine** — author an effect as text and run it on a running device, compiled to native machine code so it executes at near-hand-written speed in the render hot path. The broader design lives in [livescripts-analysis-top-down.md](../../backlog/livescripts-analysis-top-down.md) (a backlog design study); this page documents the module. A scripted effect carries its **script source** as an editable, persisted multi-line text control (a resizable `textarea` in the UI), and a front-end (lexer → parser → IR → per-ISA assembler) compiles it to native code on the next tick. The grammar is a function-call statement with **expression arguments** — any argument may be a literal or a nested call: @@ -46,16 +46,16 @@ The controls are **derived from `source`** (one per declared `uint8` control; th ## Prior art -MoonLive's native-codegen approach — compile a small C-like language straight to machine code and call it as a function, so a live-authored effect runs at near hand-written speed — was pioneered by **Yves Bazin (hpwit)** in **[ESPLiveScript](https://github.com/hpwit/ESPLiveScript)**: a from-scratch tokenizer, parser, and Xtensa code generator that drives a 12,288-LED panel at ~85 fps where interpreted languages (Lua, Gravity) managed 3–10. That result is what makes "go native, not interpreted" the right call, and ESPLiveScript is the reference MoonLive is built against — studied closely, credited, and written fresh against projectMM's architecture, never copied, per [*Industry standards, our own code*](../../../../CLAUDE.md#principles). The live-scripting idea in this ecosystem also descends from **ARTI-FX / ARTI** (the interpreted-effects runtime in WLED MoonModules), which proved the load-a-script-and-run-it-live loop end to end. The host-binding surface (`setRGB`/`setRGBXY`/`setRGBXYZ`) is modelled on the **MoonLight** [effects tutorial](https://moonmodules.org/MoonLight/moonlight/effects-tutorial/). +MoonLive's native-codegen approach — compile a small C-like language straight to machine code and call it as a function, so a live-authored effect runs at near hand-written speed — was pioneered by **Yves Bazin (hpwit)** in **[ESPLiveScript](https://github.com/hpwit/ESPLiveScript)**: a from-scratch tokenizer, parser, and Xtensa code generator that drives a 12,288-LED panel at ~85 fps where interpreted languages (Lua, Gravity) managed 3–10. That result is what makes "go native, not interpreted" the right call, and ESPLiveScript is the reference MoonLive is built against — studied closely, credited, and written fresh against projectMM's architecture, never copied, per [*Industry standards, our own code*](../../../CLAUDE.md#principles). The live-scripting idea in this ecosystem also descends from **ARTI-FX / ARTI** (the interpreted-effects runtime in WLED MoonModules), which proved the load-a-script-and-run-it-live loop end to end. The host-binding surface (`setRGB`/`setRGBXY`/`setRGBXYZ`) is modelled on the **MoonLight** [effects tutorial](https://moonmodules.org/MoonLight/moonlight/effects-tutorial/). ## Tests -[unit_moonlive_fill](../../../../test/unit/core/unit_moonlive_fill.cpp) runs the engine path in-process on the desktop host backend (`compile`/`run`, the animated routine, zero-lights, recompile, `free`, the `allocExec`/`writeExec`/`freeExec` round-trip, the buffer-shape guards). [unit_moonlive_ir](../../../../test/unit/core/unit_moonlive_ir.cpp) pins the **behavioral golden** — a compiled `fill` and the hand-encoded reference render an identical buffer — plus setRGB's single-pixel write and the runtime bounds guard. [unit_moonlive_compiler](../../../../test/unit/core/unit_moonlive_compiler.cpp) pins the expression grammar (`random16` in any/every argument slot, uint16 bounds), the parser diagnostics (no crash on malformed input), live recompile, and the **domain-neutral** property: with an empty builtin table the core knows *no* functions, and a host can register an arbitrary name against the same machinery. +[unit_moonlive_fill](../../../test/unit/core/unit_moonlive_fill.cpp) runs the engine path in-process on the desktop host backend (`compile`/`run`, the animated routine, zero-lights, recompile, `free`, the `allocExec`/`writeExec`/`freeExec` round-trip, the buffer-shape guards). [unit_moonlive_ir](../../../test/unit/core/unit_moonlive_ir.cpp) pins the **behavioral golden** — a compiled `fill` and the hand-encoded reference render an identical buffer — plus setRGB's single-pixel write and the runtime bounds guard. [unit_moonlive_compiler](../../../test/unit/core/unit_moonlive_compiler.cpp) pins the expression grammar (`random16` in any/every argument slot, uint16 bounds), the parser diagnostics (no crash on malformed input), live recompile, and the **domain-neutral** property: with an empty builtin table the core knows *no* functions, and a host can register an arbitrary name against the same machinery. The grammar + bounds guard are verified live on the S3/Olimex (Xtensa) by editing the `source` control — the device compiles the expression on-chip and renders it. -[scenario_MoonLiveEffect_livescript](../../../../test/scenarios/light/scenario_MoonLiveEffect_livescript.json) exercises the effect **as a wired MoonModule** — what the unit tests can't reach: add it, live-edit the `source` to recolour (recompile), push a broken script (`MoonLive::compile` fails, frees the previous code, `MoonLiveEffect` reports the parse error in the status and renders dark — no crash), recover, resize the grid to 1×1 and back while rendering (the every-grid-size hard rule), then remove and re-add (exec memory re-acquired clean). It runs in-process on the desktop backend each commit, and the same JSON runs live over REST against the device backends. The Xtensa/RISC-V backends are validated by the live S3/P4 runs (a `MoonLiveEffect` on a Layer lights the grid from its `source`), which the desktop tests can't reach. +[scenario_MoonLiveEffect_livescript](../../../test/scenarios/light/scenario_MoonLiveEffect_livescript.json) exercises the effect **as a wired MoonModule** — what the unit tests can't reach: add it, live-edit the `source` to recolour (recompile), push a broken script (`MoonLive::compile` fails, frees the previous code, `MoonLiveEffect` reports the parse error in the status and renders dark — no crash), recover, resize the grid to 1×1 and back while rendering (the every-grid-size hard rule), then remove and re-add (exec memory re-acquired clean). It runs in-process on the desktop backend each commit, and the same JSON runs live over REST against the device backends. The Xtensa/RISC-V backends are validated by the live S3/P4 runs (a `MoonLiveEffect` on a Layer lights the grid from its `source`), which the desktop tests can't reach. ## Source -[MoonLive.h](../../../../src/core/moonlive/MoonLive.h) · [MoonLiveBuiltins.h](../../../../src/core/moonlive/MoonLiveBuiltins.h) · [MoonLiveCompiler.h](../../../../src/core/moonlive/MoonLiveCompiler.h) · [MoonLiveIr.h](../../../../src/core/moonlive/MoonLiveIr.h) · [MoonLiveBuiltins_light.h](../../../../src/light/moonlive/MoonLiveBuiltins_light.h) · [MoonLiveEffect.h](../../../../src/light/moonlive/MoonLiveEffect.h) +[MoonLive.md](../core/moxygen/MoonLive.md) · [MoonLiveBuiltins.md](../core/moxygen/MoonLiveBuiltins.md) · [MoonLiveCompiler.md](../core/moxygen/MoonLiveCompiler.md) · [MoonLiveIr.md](../core/moxygen/MoonLiveIr.md) · [MoonLiveEffect.md](moxygen/MoonLiveEffect.md) diff --git a/docs/moonmodules/light/archive/BlendMap.md b/docs/moonmodules/light/archive/BlendMap.md deleted file mode 100644 index 2826d96f..00000000 --- a/docs/moonmodules/light/archive/BlendMap.md +++ /dev/null @@ -1,15 +0,0 @@ -# BlendMap - -`blendMap` is a free function that reads a Layer's buffer and writes mapped output into a destination buffer, called by the [Drivers](Drivers.md) container for each enabled layer each frame. It takes a blend op (`Overwrite` / `Alpha` / `Additive`), an `opacity` (0–255), and a `clearFirst` flag — the bottom (first-composited) layer passes `clearFirst=true` so physical cells with no source (a sparse layout's lattice gaps) stay black; layers above pass `false` to accumulate onto the frame below. - -The combine math is integer-only (the hot-path per-light rule), with one tight specialised loop per op chosen once per layer: - -- **Overwrite** (the default / bottom layer): plain copy, no read-back. For a dense grid (no LUT) it's a `memcpy`; for a single-write LUT (mirror, shuffle, sparse box→driver) it copies source→destination per mapped light. A non-overwriting LUT (one that folds several logical lights onto one physical cell) routes through the additive accumulate path so the overlaps sum-with-clamp rather than last-writer-win. -- **Additive**: `dst = clamp(dst + src·opacity)` — sum with saturation at 255, opacity scaling the source. -- **Alpha** (over): `dst = (src·α + dst·(255−α)) / 255` — the textbook 8-bit alpha-over, division by 255 via the fast `(x + (x>>8) + 1) >> 8` reciprocal. Full opacity (255) collapses to a plain overwrite (no blend cost). - -A dense-grid layer has no LUT, so its buffer blends 1:1 (source index = physical index, no lookup); a layer with a LUT maps each logical light to its physical destination(s) first. Physical indices come from the LUT, which is built in-range from the shared Layouts, so they address the buffer in bounds by construction. The per-Layer `blendMode`/`opacity` controls that select the op live on [Layer](Layer.md#blendmode-opacity-controls); Drivers reads them and the layer stack order. - -## Source - -[BlendMap.h](../../../../src/light/layers/BlendMap.h) diff --git a/docs/moonmodules/light/archive/Buffer.md b/docs/moonmodules/light/archive/Buffer.md deleted file mode 100644 index a6cd40ff..00000000 --- a/docs/moonmodules/light/archive/Buffer.md +++ /dev/null @@ -1,25 +0,0 @@ -# Buffer - -Contiguous light data buffer. Used by both layers (effects write into it) and driver groups (drivers read from it). When memory allows, layers and driver groups each have their own buffer (enabling parallelism). When memory is tight, a single buffer is shared. - -## Storage - -A raw `uint8_t*` (not `RGB*`) keeps the buffer flexible for any channel layout — RGB, RGBW, or multi-channel DMX fixtures — addressed by channel count + offset. Allocated via `platform::alloc` (PSRAM when available) outside the hot path and reused every frame; a `std::span<uint8_t>` view is the zero-cost safe accessor. - -## Locking - -Semaphores are expensive (~150 bytes on ESP32), so prefer lock-free patterns: an atomic pointer swap for double-buffering, a single-slot SPSC handoff, or one shared semaphore across layers rather than one per layer. - -## Tests - -[Unit tests: Buffer](../../../tests/unit-tests.md#buffer) — allocate, clear, move semantics, double-free safety, zero-size edge case. - -## Prior art - -### MoonLight — VirtualLayer.virtualChannels ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/VirtualLayer.h)) - -Raw `uint8_t*` buffer, sized by `channelsPerLight * nrOfLights`. Supports RGB, RGBW, and multi-channel DMX fixtures via LightsHeader offsets. - -## Source - -[Buffer.h](../../../../src/light/layers/Buffer.h) diff --git a/docs/moonmodules/light/archive/Drivers.md b/docs/moonmodules/light/archive/Drivers.md deleted file mode 100644 index ef121924..00000000 --- a/docs/moonmodules/light/archive/Drivers.md +++ /dev/null @@ -1,54 +0,0 @@ -# Drivers - -![Drivers controls](../../../assets/light/drivers/Drivers.png) - -Top-level container for one or more drivers. The consumer side of the pipeline — owns the shared output buffer (when memory allows) and performs blend+map from every layer's buffer into it each frame. - -> **Naming convention.** Capital `Drivers` is the container class; lowercase "driver"/"drivers" is the English singular/plural for individual `DriverBase` children. Capitalisation disambiguates "the Drivers container" from "two drivers running". Same rule for `Layouts`/layout and `Layers`/layer. - -## Shared output buffer - -The shared output buffer is necessary because blend+map writes to arbitrary physical positions (via LUT) — the output is not filled sequentially. A driver cannot read chunk-by-chunk until the full buffer is populated. - -Exception: when exactly one layer is enabled AND its mapping is 1:1 unshuffled (no LUT — grid layout, no serpentine), Drivers skips its own buffer and lets drivers read directly from the layer's buffer (the zero-copy fast path, at the cost of parallelism). See [architecture.md § Parallelism](../../../architecture.md#parallelism). - -It uses the same `Buffer` type a Layer does, sized by the Layouts container. - -## Multi-layer composition - -When two or more layers are enabled, Drivers composites them into the shared output buffer each frame, in [Layers](Layers.md) container order (bottom→top, via `forEachEnabledLayer`). The bottom layer clears and overwrites the buffer; each layer above blends onto the accumulated frame per its own `blendMode` and `opacity` (the inert per-Layer controls — see [Layer](Layer.md#blendmode-opacity-controls)). Drivers owns the orchestration because only it sees the stack order and the output buffer; the layers carry only the parameters. The per-pixel blend math lives in [BlendMap](BlendMap.md) (integer-only, per the hot-path rule). A full-opacity overwrite/additive layer pays no alpha arithmetic, so the per-frame cost scales with the enabled-layer count. With a single enabled layer there is no composite: the fast path above applies (no-LUT → zero-copy; with a LUT → one blend+map pass into the output buffer). - -## Output correction - -The Drivers container owns the shared output-correction state and exposes two controls; each *physical* driver child (ArtNet today, future LED drivers) applies it per-light as it reads the source buffer. Preview ignores it (shows the raw logical buffer). - -| Control | Type | Description | -|---|---|---| -| `brightness` | uint8 (0–255) | Global brightness. Scales every channel through a 256-entry LUT (`(v × brightness) / 255`). Changing it rebuilds only the LUT on the cheap `onUpdate` tier — no pipeline realloc, so the slider is fluent. Gamma / white-balance fold into this LUT later as a per-channel R/G/B split. | -| `lightPreset` | select | The physical wire format: channel order and whether the light is RGBW. Options: `RGB`, `RBG`, `GRB`, `GBR`, `BRG`, `BGR`, `RGBW`, `GRBW`. Defaults to `GRB` — the WS2812/SK6812 wire order, so a strip shows correct colours out of the box (PreviewDriver reads the RGB source buffer directly and is unaffected). RGBW presets make each driver emit 4 channels per light with white derived as `min(R,G,B)` from the (brightness-scaled) RGB. | -| `palette` | select | The **global active colour palette** (`Rainbow`, `Party`, `Lava`, `Ocean`, …). Palette-driven effects read it via `Palettes::active()` and colour their pixels through `colorFromPalette(index)`, so changing this recolours every such effect live. The select index expands the chosen gradient into the active 16-entry palette on `onUpdate` (cheap, off the hot path). Drivers owns this as a global render parameter, alongside `brightness` and `lightPreset`. Palette model + names follow FastLED's, credited as prior art; implementation in `src/light/Palette.h`. | - -The state lives on `Correction` (`src/light/drivers/Correction.h`): a brightness LUT, channel-order table, output channel count, derive-white flag. `Drivers::onUpdate` rebuilds it on a `brightness`/`lightPreset` change and hands each child a `const Correction*`. Every driver sees the same composited output; per-driver layer assignment (different drivers reading different layers) is a [backlog](../../../backlog/README.md) item. - -## Per-driver source window (`start` / `count`) - -A **window-aware output driver** reads the shared source buffer and outputs a contiguous slice of it — its *window*. `DriverBase` provides two controls for this via an opt-in `addWindowControls()` helper, used by the output drivers (the LED drivers and the network sink). It is **not** forced on every `DriverBase` subclass — a driver that outputs the whole buffer (e.g. PreviewDriver, which renders the full logical buffer for the UI) simply doesn't call it and has no `start`/`count`: - -| Control | Type | Description | -|---|---|---| -| `start` | uint16 | First source-buffer light this driver outputs. Default `0`. | -| `count` | uint16 | Number of lights to output from `start`. Default `0` = **to the end of the buffer**. The slice is `[start, start+count)`, clamped to the buffer (a `start` past the end yields an empty slice — the driver idles, no out-of-bounds read). | - -This makes light distribution **explicit and order-independent**: each driver names its own slice, so reordering drivers does not change which lights each outputs (it only changes tick order). It is the alternative to a "split the buffer by sibling order" model some controllers use — here the user (or catalog) says which slice goes where. - -The motivating case: an **onboard status LED** and a **main strip** as two driver instances on the same buffer — one with window `[0, 1)` (the single onboard LED on its own pin), the other with window `[1, …)` (the strip on its pin, starting one light in). Neither steals the other's lights. Within a driver's window, the LED drivers' `pins` / `ledsPerPin` distribute *that slice* across the pins; the network sink maps its slice onto `universe_start` (the protocol offset is separate from the buffer `start`). - -## Prior art - -### MoonLight — PhysicalLayer ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/PhysicalLayer.h)) - -Owns `channelsD` (display buffer). `compositeLayers()` maps virtualChannels → channelsD. Parallelism via semaphore: driver signals completion, compositor writes. - -## Source - -[Drivers.h](../../../../src/light/drivers/Drivers.h) diff --git a/docs/moonmodules/light/archive/EffectBase.md b/docs/moonmodules/light/archive/EffectBase.md deleted file mode 100644 index 9fb7f5a5..00000000 --- a/docs/moonmodules/light/archive/EffectBase.md +++ /dev/null @@ -1,31 +0,0 @@ -# EffectBase - -Light-domain MoonModule subclass for effects. Adds rendering context. - -## Design - -`EffectBase` is a zero-state convenience layer: it holds no data of its own, just accessors (`buffer()`, `width()`, `height()`, `depth()`, `elapsed()`, …) that forward to the parent `Layer`. An effect reads its rendering context through these instead of caching a `Layer*` and the dimensions itself. `DriverBase` plays the same role for drivers against `Drivers`. - -## Animation guidelines - -Effects use **BPM** (beats per minute) for speed controls, not abstract 0-255 ranges. This gives users a musical reference: 60 BPM = one beat per second, 120 BPM = two beats per second. Default is 60 BPM. - -Animation must be **resolution-independent**: multiply the time offset by the panel dimension (width or height) so the perceived visual speed is the same regardless of display size. Without this, large displays look sluggish and small displays look frantic at the same BPM. - -Animation is driven by **elapsed millis**, not frame count. This ensures consistent speed regardless of FPS. The speed slider controls the animation dynamics, never the framerate — FPS should always be maximal for smooth motion. - -## Dimensions and auto-extrusion - -`dimensions()` (D3 default; the `.h` documents the per-axis contract) is a claim about which axes the effect *iterates*, not what the layer has — so `loop()` must read `width()`/`height()`/`depth()` at frame time and never hardcode a bound. The `dim` int (1/2/3) is emitted in `/api/types`; the UI derives the 📏/🟦/🧊 chip from it, so it isn't repeated in each module's `tags()`. See [architecture.md § Effects](../../../architecture.md#effects) for the live declaration per shipped effect, and `unit_Layer_extrude.cpp` for the pinned contract tests. - -## Prior art - -### MoonLight — Node + VirtualLayer ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonBase/Nodes.h), [source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/VirtualLayer.h)) - -- Effects access `layer->width()`, `layer->height()`, `layer->depth()` directly via the VirtualLayer pointer. No separate EffectBase. -- Buffer access via `layer->virtualChannels` (raw byte array). -- Time via `timeMicros()`. - -## Source - -[EffectBase.h](../../../../src/light/effects/EffectBase.h) diff --git a/docs/moonmodules/light/archive/Layer.md b/docs/moonmodules/light/archive/Layer.md deleted file mode 100644 index 0934fe0d..00000000 --- a/docs/moonmodules/light/archive/Layer.md +++ /dev/null @@ -1,76 +0,0 @@ -# Layer - -A `Layer` MoonModule (role `ModuleRole::Layer`, child of the [Layers](Layers.md) container) owns a buffer, a mapping LUT, effects, and modifiers. References a shared [Layouts](Layouts.md). - -> **Naming convention.** Capital `Layer` and `Layers` are class names; lowercase "layer"/"layers" is the English singular/plural — used freely when the sentence makes the meaning clear. Capitalisation disambiguates "the Layers container" from "two layers stacked". Same rule for `Layouts`/layout and `Drivers`/driver. - -## Ownership - -- **Buffer** — logical light data, sized to logical dimensions -- **MappingLUT** — maps logical lights to physical positions -- **Effects** (ordered list) — write lights into buffer. No hard-coded max — dynamic list (heap-allocated, grown as needed). -- **Modifiers** (ordered list) — transform LUT or light values. Same dynamic list approach. - -## blendMode / opacity controls - -Two controls govern how this Layer composites onto the layers below it: `blendMode` (a select — `alpha` over, or `additive` sum-with-clamp) and `opacity` (`uint8`, 0 = invisible, 255 = full). They are **inert on the Layer** — the Layer never reads them; it just carries them so they travel through add / delete / reorder with no separate synchronised list. The [Drivers](Drivers.md) container reads each enabled Layer's two values plus the [Layers](Layers.md) container's child order and does the actual compositing (bottom layer overwrites, each layer above blends per its mode + opacity). The bottom (first-composited) Layer's `blendMode`/`opacity` are moot — nothing sits under it. The blend math itself lives in [BlendMap](BlendMap.md). Precedent for "value here, logic in Drivers": the per-X `Correction` data Drivers applies. - -## Key operations - -### rebuildLUT() - -Cold-path. Called when layout or modifier controls change. - -1. Gets physical dimensions and total light count from Layouts -2. Applies static modifiers to compute logical dimensions (e.g. 128x128 → 64x64 with mirror X+Y) -3. Allocates buffer to logical dimensions -4. Allocates LUT -5. For each logical light, asks modifier for physical destinations via virtual interface -6. Without modifier AND with grid layout (no sparse, no serpentine, X-then-Y order): 1:1 unshuffled — `oneToOneMapping` flag set, mapping table skipped entirely - -### render(elapsed_ms) - -Hot-path. Called every frame. - -1. Creates rendering context with buffer, logical dims, elapsed time -2. Runs each effect in order (all write to same buffer) -3. **After each effect's `loop()`, calls `extrude(eff->dimensions())`** — duplicates the effect's written slice across the axes it doesn't iterate (see below) -4. Runs dynamic modifiers in order via virtual `transformLights()` (each transforms the buffer) - -### extrude(effectDim) - -Lets a low-dimensional effect work on a higher-dimensional layer without per-effect changes. Reads the effect's `dimensions()` declaration and fills the unused axes from the slice the effect wrote: - -- `Dim::D3` — early return; no work, no allocation, one comparison + branch. -- `Dim::D2` — `memcpy` the z=0 slice across every z>0 (guarded by `depth > 1`). -- `Dim::D1` — `memcpy` the y=0 row across every y>0 within z=0, then duplicate z=0 across z (each guarded). - -Hot-path cost is zero for D3 effects (the default) and zero when the layer's unused axes are size 1 (a D2 effect on a 2D layer, a D1 effect on a 1D layer). Real `memcpy` work only happens when the layer has more dimensions than the effect writes — exactly the case the framework is meant to handle. - -See [EffectBase § Dimensions and auto-extrusion](EffectBase.md#dimensions-and-auto-extrusion) for the effect-side contract and [Unit tests: Layer](../../../tests/unit-tests.md#layer) for the pinned tests (see `unit_Layer_extrude.cpp`). - -## Status - -The Layer's status line (the `MoonModule` status slot) shows the **logical** box the effects render into — `"<w>×<h>×<d>"`, the dimensions its modifiers reshape. This differs from the physical bounding box shown on the [Layouts](Layouts.md#status) container: a Mirror-XY modifier on a 128×128 physical layout renders into a 64×64 logical box (the half that gets folded), so the Layer reads `64×64×1` while Layouts reads `128×128×1`. The gap between the two is the modifier's effect, made visible. - -The same slot carries memory-degradation warnings (`Severity::Warning`/`Error`) when a build can't fit: `"modifier LUT skipped — not enough memory"`, `"sparse LUT build failed — not enough memory"`, `"buffer reduced — not enough memory"`, `"buffer allocation failed — not enough memory"`. A warning wins over the neutral box line — when the Layer is degraded, that's what the user needs to see. Recomputed on every rebuild (`onBuildState`), not per tick. - -## EffectBase overlap - -Consider whether Layer itself can provide the rendering context (buffer, dims, elapsed time) directly to effects, eliminating the need for a separate EffectBase class. The layer already knows everything the effect needs. - -## Prior art - -### MoonLight — VirtualLayer ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/VirtualLayer.h)) - -- `startPct`/`endPct` as Coord3D percentages (0-100) of the total fixture. -- `oneToOneMapping` flag for fast path. -- `brightness` per layer (0-255) + `transitionBrightness` for smooth fade-in/out. -- `virtualChannels` — per-layer buffer. -- `effectDimension` — 1D/2D/3D. -- `nodes` vector for effects/modifiers (dynamic, not fixed-capacity). -- `forEachLight` — per-logical-light iteration that asks the modifier for physical destinations; the LUT build uses the same per-light virtual-dispatch pattern. - -## Source - -[Layer.h](../../../../src/light/layers/Layer.h) diff --git a/docs/moonmodules/light/archive/Layers.md b/docs/moonmodules/light/archive/Layers.md deleted file mode 100644 index ad8a9a02..00000000 --- a/docs/moonmodules/light/archive/Layers.md +++ /dev/null @@ -1,23 +0,0 @@ -# Layers - -![Layers controls](../../../assets/core/Layers.png) - -Top-level container for one or more layers. Each layer renders independently into its own buffer; the Drivers container composes those buffers downstream. - -> **Naming convention.** Capital `Layers` is the container class; lowercase "layer"/"layers" is the English singular/plural for individual `Layer` children. Capitalisation disambiguates "the Layers container" from "two layers stacked". Same rule for `Layouts`/layout and `Drivers`/driver. - -## Why a container - -Multi-layer composition (alpha-blend, additive, layered overlays) needs a place to walk every layer in order so drivers can merge their buffers before consuming the result. Layers is that place. With one layer inside it the container is a thin pass-through: `loop()` runs the single child and returns; behaviour is byte-identical to the single-layer pipeline. - -The container owns no buffer: each layer owns its own, and the Drivers container owns the composited output. It wires the shared Layouts into every child so each can size its buffer. Two queries serve the Drivers compositor: `activeLayer()` (the first enabled child) answers physical dimensions and is the source for the single-layer fast path, and `forEachEnabledLayer(cb)` walks the enabled children in container order (bottom→top) — the order Drivers blends them, with `cb(layer, isFirst)` marking the bottom layer that clears the buffer. `enabledLayerCount()` lets Drivers pick the fast path (one enabled layer → hand its buffer straight to the driver) versus the composite path (≥2 → blend into the output buffer). The blend modes and the value-on-Layer / logic-in-Drivers split are documented on [Layer](Layer.md#blendmode-opacity-controls) and [Drivers](Drivers.md). - -## Prior art - -### MoonLight — VirtualLayer / PhysicalLayer ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight)) - -MoonLight's `PhysicalLayer` runs N `VirtualLayer`s and composites their buffers into the display channel. Same idea, different shape: Drivers (not Layers) does the compositing here. - -## Source - -[Layers.h](../../../../src/light/layers/Layers.h) diff --git a/docs/moonmodules/light/archive/Layouts.md b/docs/moonmodules/light/archive/Layouts.md deleted file mode 100644 index 612d9480..00000000 --- a/docs/moonmodules/light/archive/Layouts.md +++ /dev/null @@ -1,31 +0,0 @@ -# Layouts - -![Layouts controls](../../../assets/core/Layouts.png) - -Top-level container for one or more layouts. Shared by every layer in the Layers container — defines the physical light topology of the installation. - -> **Naming convention.** Capital `Layouts` is the container class; lowercase "layout"/"layouts" is the English singular/plural for individual `LayoutBase` children. Capitalisation disambiguates "the Layouts container" from "two layouts stacked". Same rule for `Layers`/layer and `Drivers`/driver. - -Coordinate iteration is owned by the container, not the layer: `Layouts::forEachCoord` walks every child layout's coordinates, offsetting physical indices so multiple layouts (e.g. 16 strips making one panel) don't overlap. A layer *uses* those coordinates to build its LUT. `totalLightCount()` — the sum across enabled children — sizes both the layer buffer and the driver output buffer. Children inherit `LayoutBase` and implement its virtual interface directly (no wrapper). - -## Disabling a layout - -Disabling a layout child (the `enabled` toggle in the UI) removes its lights from the LUT entirely. Indices of any layouts after it shift down to close the gap: with two grids of 4 and 2 lights, disabling the first leaves the second at indices 0–1, and `totalLightCount` drops from 6 to 2. A `Scheduler::buildState()` fires from the HTTP handler so the LUT, layer buffer, and driver output buffer reallocate. - -Side effect: ArtNet universe assignments shift with the indices. To keep driver-to-fixture mapping stable across enable changes, disable the driver instead of the layout. - -## Status - -The container's status line (the `MoonModule` status slot, rendered by the UI) shows the **physical** setup it describes: `"<N> lights · <W>×<H>×<D>"` — the total light count summed across all enabled layout children (this is the size of the driver output buffer) and the physical bounding box (the extent of all light coordinates, the size of the dense render buffer). For a dense grid the count equals the box volume; for a sparse layout (e.g. a sphere shell) the count is smaller than the box — the gap is the at-a-glance signal that the layout is sparse. An empty setup (no lights) reports `Warning` severity. Recomputed on every rebuild (`onBuildState`), not per tick. - -## Reordering - -Layout children are reordered by drag-and-drop in the UI (`POST /api/modules/<name>/move` with `{"to": <index>}`). **Insert semantics, not swap:** the dragged layout takes the drop target's slot and the others shift to fill — the standard reorderable-list behaviour (Finder, Trello, SortableJS). Dropping onto a row (rather than a between-rows gap) means the landing is the target's absolute index, so dragging down lands after the target and dragging up lands before it. Order matters: it sets the physical index range each layout occupies, which drives ArtNet universe assignment. Same `move` op and semantics apply to every container (effects/modifiers under a Layer, drivers under Drivers). - -## Rebuild propagation - -A layout control change propagates to every layer (LUT rebuild) and to the Drivers container (output-buffer reallocation) via the mechanism in [architecture.md § Rebuild propagation](../../../architecture.md#event-triggering-between-modules). - -## Source - -[Layouts.h](../../../../src/light/layouts/Layouts.h) diff --git a/docs/moonmodules/light/archive/LightConfig.md b/docs/moonmodules/light/archive/LightConfig.md deleted file mode 100644 index ca584c9a..00000000 --- a/docs/moonmodules/light/archive/LightConfig.md +++ /dev/null @@ -1,38 +0,0 @@ -# Light Value - -NOT a fixed 3-byte RGB struct. The system must support RGB (3 bytes), RGBW (4 bytes), and multi-channel DMX fixtures (up to 32 channels per light). - -## Design - -The buffer is raw `uint8_t*` with configurable `channelsPerLight` and per-channel offsets. There is no `RGB` struct that all code passes around — instead, effects and drivers work with the buffer directly using the channel configuration. - -Channel offsets define where each color/function channel lives within a light's data: -- `offsetRed`, `offsetGreen`, `offsetBlue` — primary RGB -- `offsetWhite` — white channel (RGBW, RGBCCT) -- `offsetBrightness` — separate brightness channel (PAR lights) -- `offsetPan`, `offsetTilt`, `offsetZoom`, `offsetRotate`, `offsetGobo` — moving heads - -This means the same buffer and pipeline handles LEDs and DMX fixtures without separate code paths. - -## Color math utilities - -Pure functions (not tied to a struct): -- `hsvToRgb(h, s, v)` — integer HSV to RGB (6-sector, no floats) -- `scale8(val, scale)` — `(val * scale) >> 8` -- `blend(r1,g1,b1, r2,g2,b2, amount)` — per-channel lerp - -These operate on individual channel values, not on a struct. They live in core (platform-independent). - -## Tests - -[Unit tests: Color](../../../tests/unit-tests.md#color) — hsvToRgb at cardinal hues, white/black edge cases, scale8, constexpr verification. - -## Prior art - -### MoonLight — LightsHeader ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/LightsHeader.h)) - -48-byte metadata struct: `channelsPerLight` (3-32), per-channel offsets for RGB, RGBW, RGBCCT, brightness, pan/tilt/zoom/rotate/gobo. One struct handles LEDs AND DMX fixtures. Sent to frontend for preview rendering. - -## Source - -Channel model: [light_types.h](../../../../src/light/light_types.h). Colour math: [color.h](../../../../src/core/color.h). diff --git a/docs/moonmodules/light/archive/MappingLUT.md b/docs/moonmodules/light/archive/MappingLUT.md deleted file mode 100644 index e95cc0e1..00000000 --- a/docs/moonmodules/light/archive/MappingLUT.md +++ /dev/null @@ -1,51 +0,0 @@ -# MappingLUT - -Lookup table mapping logical light indices to physical light indices. - -## Mapping types - -Four mapping types describe how logical lights relate to physical lights: - -| Type | Meaning | LUT needed | Example | -|------|---------|-----------|---------| -| **1:1 identical** | logical index == physical index | No | Grid, no serpentine, no modifiers | -| **1:1 shuffled** | each logical → one physical, reordered | Yes | Grid with serpentine | -| **1:0 unmapped** | logical has no physical output | Yes | Sparse layouts (wheel) | -| **1:N multimap** | logical → multiple physical | Yes | Mirror/clone modifier | - -## API - -The code API answers one question: **does this LUT have a mapping table?** - -- **`hasLUT()`** — returns true if a mapping table is allocated. Covers 1:1 shuffled, 1:0, and 1:N. -- **`setIdentity(count)`** — sets identity mode (1:1 identical). No table allocated, `hasLUT()` returns false. `forEachDestination(i, cb)` calls `cb(i)` — logical index IS the physical index. -- **`build(logicalCount, maxDest)`** — allocates CSR arrays for non-identity mapping. `hasLUT()` returns true. -- **`overwrites()` / `setOverwrites(bool)`** — whether each physical destination is written by at most one source (default true). When true, `blendMap` plain-copies (≈4× faster than additive — no read-back/clamp); every current producer (mirror, serpentine, sparse box→driver) is single-write. Set false only for a map that intentionally folds multiple sources onto one destination (future multi-layer compositing), where `blendMap` additively blends with clamping. - -Callers don't need to know which mapping type is used — they only need to know whether a table exists. Drivers checks `hasLUT()` to decide whether to allocate an output buffer. BlendMap checks `hasLUT()` to choose between memcpy (identity) and LUT-based mapping. - -Naming: `setIdentity()` / `hasLUT()` are used rather than a "one-to-one" flag because "one-to-one" is ambiguous — it reads as covering all 1:1 mappings, but the table-free fast path applies only to the *sequential identity* case (logical index == physical index). - -## Storage - -Uses `nrOfLightsType` typedef (see [architecture.md § 3D from the start](../../../architecture.md#3d-from-the-start)): `uint16_t` on no-PSRAM, `uint32_t` on PSRAM. - -CSR (Compressed Sparse Row) format: two arrays — `offsets[logicalCount + 1]` stores where each entry's destinations start, `destinations[]` stores the flat list of physical indices. For entry `i`, destinations are `destinations[offsets[i] .. offsets[i+1])`. - -**Paged destinations (no-PSRAM fragmentation fallback).** The `destinations` array for a many-to-one modifier on a large grid can be tens of KB (a 128×128 XY mirror → ~64 KB). On a no-PSRAM ESP32 the largest *contiguous* free block can be smaller than that even when total free heap is fine. So `build()` tiers the destinations allocation: (1) a single contiguous block if one fits — the flat array, and the only path PSRAM boards and small grids ever take; (2) if no single block fits but total heap allows it, the destinations split into fixed 4096-entry (8 KB, power-of-two) pages that each fit a fragmented heap; (3) if total free minus the platform heap reserve is still too small, `build()` returns false and the Layer degrades to 1:1 with a warning. `offsets` is always a single allocation (small). `forEachDestination()` walks the flat array directly in the single-block case (`isPaged()` false) and switches pages at each 4096 boundary in the paged case — `paged_` is checked once per blend, so boards that never page pay nothing. Output is identical either way; paging is an allocation detail. - -Memory: `estimateBytes(logicalCount, maxDest)` returns the total allocation size. `memoryUsed()` returns actual bytes allocated (0 for identity). - -## Size information - -`totalDestinations` (total physical lights) is provided by the Layouts container. Used by each Layer and by the Drivers container to allocate their buffers. Destinations are therefore always within valid bounds. - -## Prior art - -### MoonLight — PhysMap ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/PhysMap.h)) - -Memory-optimal union. 2 bytes (no-PSRAM) or 4 bytes (PSRAM). Map type stored IN each entry. `oneToOneMapping` and `allOneLight` fast path flags. `forEachLightIndex()` for 1:N iteration. projectMM renames `oneToOneMapping` → `setIdentity()` / `!hasLUT()` because "one-to-one" reads as covering all 1:1 mappings, but the table-free fast path applies only to the sequential identity case. - -## Source - -[MappingLUT.h](../../../../src/light/layers/MappingLUT.h) diff --git a/docs/moonmodules/light/archive/ModifierBase.md b/docs/moonmodules/light/archive/ModifierBase.md deleted file mode 100644 index d9f25d7f..00000000 --- a/docs/moonmodules/light/archive/ModifierBase.md +++ /dev/null @@ -1,31 +0,0 @@ -# ModifierBase - -Light-domain MoonModule subclass for modifiers. A modifier is a **coordinate transform** that reshapes how a Layer's effect output maps onto the physical lights. Multiple modifiers on one Layer **compose**: they apply in child order, each reshaping the result of the one below (Region *then* Multiply-mirror *then* Rotate). - -## The fold contract - -A Layer builds its mapping by walking the **physical** lights and folding each through every enabled modifier in order — the composition `M₁∘M₂∘…∘Mₙ` collapsed into one mapping, so the per-frame render stays a single lookup. Three hooks, each a no-op by default so a modifier implements only what it needs: - -- **`modifyLogicalSize(Coord3D& size)`** — static, build-time, once per rebuild in child order. Folds the logical box (Multiply divides it, Region crops it, a mask leaves it). The running `size` starts at the physical box; each modifier reshapes it. A modifier that needs its box in the per-light fold **stashes** it here (the MoonLight `modifierSize` pattern), so the Layer keeps no per-stage box array. -- **`bool modifyLogical(Coord3D& pos)`** — static, build-time, per physical light in child order. Folds a coordinate into this stage's logical space in place, reading any box it needs from its own stash. Returns **`false` to reject** — the coordinate has no logical source (a mask drops it, a region light falls outside the crop, a Multiply leftover-edge pixel has no tile). A bool, not a sentinel coord, so a later modifier's `% size` can't alias a sentinel back into range. -- **`modifyLive(Coord3D& pos, const Coord3D& logical)`** — dynamic, per-frame at render time. Remaps a coordinate without rebuilding the mapping (smooth rotation/scroll). The Layer runs this pass **only** when some enabled modifier reports `hasModifyLive()`, so a static-only chain pays nothing per frame — the render path stays at full speed (pay-for-what-you-use). It is a **backward** map: for each destination cell it returns the source cell to gather, so no destination is left torn. - -A beat-driven modifier (RandomMap reshuffles on a timer) sets a flag in `loop()`; the Layer polls `consumeNeedsRebuild()` across its modifiers and rebuilds the mapping **once** if any asks, coalescing several dynamic modifiers into a single rebuild. - -`dimensions()` (the 📏/🟦/🧊 chip) advertises which axes the modifier can transform. - -## Fan-out is free - -Because the build walks physical lights, fan-out (one logical cell driving N physical lights — a Multiply kaleidoscope) emerges naturally: N physical lights fold onto the same logical cell. There is no build-time fan-out list and no product-of-multipliers ceiling — each physical light contributes at most one destination, so the mapping can never overflow. - -## Affine modifiers and the matrix reference - -Most modifiers are **non-affine** (a mask is a predicate, a tile is modulo) and express their fold directly. [RotateModifier](../modifiers/modifiers.md) is the exception and the codebase's **transform-matrix reference**: rotation is the canonical affine transform, written as an explicit integer 2×2 rotation matrix in `modifyLive`. A future affine "Transform" modifier (translate+scale+rotate+shear in one) would compose its matrix the same way and apply it through the same hook — the fold interface hosts a matrix-backed modifier with no change. - -## Prior art - -The mapping bake is the textbook image-warping pattern (precompute a coordinate transform into a spatial LUT; build it by backward mapping so no output pixel is unfilled — [Forward and Backward Mapping for Computer Vision](https://towardsdatascience.com/forward-and-backward-mapping-for-computer-vision-833436e2472/)). Collapsing a **chain** of discrete pixel folds into one index table — instead of giving each node its own frame buffer as a PC node graph (TouchDesigner, shader graphs) would — is the MCU-memory synthesis credited to **MoonLight** ([M_MoonLight.h](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h), [VirtualLayer.cpp](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/VirtualLayer.cpp)): `modifySize` / `modifyPosition` / `modifyXYZ` map to our `modifyLogicalSize` / `modifyLogical` / `modifyLive`, written fresh against our `MappingLUT`. - -## Source - -[ModifierBase.h](../../../../src/light/modifiers/ModifierBase.h) — the hook contract. The Layer-side fold build (physical→logical counting-sort, the live pass) lives in [Layer.h](../../../../src/light/layers/Layer.h). diff --git a/docs/moonmodules/light/drivers/drivers.md b/docs/moonmodules/light/drivers.md similarity index 61% rename from docs/moonmodules/light/drivers/drivers.md rename to docs/moonmodules/light/drivers.md index 19df7cb8..1d776c41 100644 --- a/docs/moonmodules/light/drivers/drivers.md +++ b/docs/moonmodules/light/drivers.md @@ -1,6 +1,6 @@ # Drivers -A driver reads its window of the [Drivers](../moxygen/Drivers.md) container's shared buffer, applies the shared [output correction](../archive/Drivers.md#output-correction) per light, and sends the result out — a wire protocol (WS2812), the network (Art-Net / E1.31 / DDP), a smart-light hub (Hue), or the web UI (Preview). Drivers are added per board through the catalog ([`deviceModels.json`](../../../../web-installer/deviceModels.json)); `PreviewDriver` is the one boot-wired driver. Every driver shares the `start` / `count` [source-window](../archive/Drivers.md#per-driver-source-window-start-count) controls (the slice `[start, start+count)` it sends). Each card links to a detail page and, where it doesn't fit the table, a **⌄ details** section below. +A driver reads its window of the [Drivers](moxygen/Drivers.md) container's shared buffer, applies the shared [output correction](moxygen/Drivers.md) per light, and sends the result out — a wire protocol (WS2812), the network (Art-Net / E1.31 / DDP), a smart-light hub (Hue), or the web UI (Preview). Drivers are added per board through the catalog ([`deviceModels.json`](../../../web-installer/deviceModels.json)); `PreviewDriver` is the one boot-wired driver. Every driver shares the `start` / `count` [source-window](moxygen/DriverBase.md) controls (the slice `[start, start+count)` it sends). Each card links to a detail page and, where it doesn't fit the table, a **⌄ details** section below. **Jump to:** [LED output](#led-output-drivers) · [Network](#network-drivers) · [Smart light](#smart-light-drivers) · [Preview](#preview-drivers) @@ -14,16 +14,18 @@ A driver reads its window of the [Drivers](../moxygen/Drivers.md) container's sh Addressable WS2812B-class LEDs over a wire, one GPIO per strand. Three peripherals do this — pick by chip: **RMT** (single/few strands, any ESP32), **LCD_CAM** (8 parallel strands, S3), **Parlio** (1–8 parallel strands, P4). Same controls, same wire contract; they differ only in how many strands clock out at once and on which chip. +<img src="../../../assets/light/drivers/RmtLedDriver.png" width="300" alt="LED output driver controls"> + - `pins` — data GPIO list, e.g. `18,17,16` (one strand each). Empty idles until set; changing it re-inits live. - `ledsPerPin` — lights per pin, matched by position; empty or short = even split of the remainder. - `loopbackTest` — on/off TX→RX loopback self-test (jumper the first pin to `loopbackRxPin`); verdict in the status field. - `loopbackTxPin` / `loopbackRxPin` — optional TX override + the RX pin for the self-test. Shown only while `loopbackTest` is on. -Origin: WS2812B on FastLED / hpwit / WLED prior art ([analysis](../../../backlog/leddriver-analysis-top-down.md)) +Origin: WS2812B on FastLED / hpwit / WLED prior art ([analysis](../../backlog/leddriver-analysis-top-down.md)) -[Tests](../../../tests/unit-tests.md#rmtleddriver) +[Tests](../../tests/unit-tests.md#rmtleddriver) -Detail: RMT [.h](../../../../src/light/drivers/RmtLedDriver.h) · [md](RmtLedDriver.md) — LCD [.h](../../../../src/light/drivers/LcdLedDriver.h) · [md](LcdLedDriver.md) — Parlio [.h](../../../../src/light/drivers/ParlioLedDriver.h) · [md](ParlioLedDriver.md) +Detail: [RMT](moxygen/RmtLedDriver.md) · [LCD](moxygen/LcdLedDriver.md) · [Parlio](moxygen/ParlioLedDriver.md) ## Network drivers @@ -42,7 +44,9 @@ Streams the buffer over UDP as **Art-Net**, **E1.31 / sACN**, or **DDP** — one Origin: MoonLight D_NetworkOut; Art-Net 4 / E1.31 / DDP specs -[Tests](../../../tests/unit-tests.md#networksenddriver) · [.h](../../../../src/light/drivers/NetworkSendDriver.h) · [detail](NetworkSendDriver.md) +[Tests](../../tests/unit-tests.md#networksenddriver) + +Detail: [technical](moxygen/NetworkSendDriver.md) ## Smart light drivers @@ -50,7 +54,7 @@ Origin: MoonLight D_NetworkOut; Art-Net 4 / E1.31 / DDP specs ### Hue 💫 · bridge -<img src="../../../assets/light/drivers/Hue driver.png" width="300" alt="A HueDriver in the UI"> +<img src="../../../assets/light/drivers/HueDriver.png" width="300" alt="A HueDriver in the UI"> Drives **Philips Hue bulbs as pixels**: each colour bulb in the driver's window becomes one pixel, pushed to the bridge over its HTTP API. Paced to the bridge's ~10 cmd/s limit — smooth ambient colour, not strobing. @@ -61,7 +65,9 @@ Drives **Philips Hue bulbs as pixels**: each colour bulb in the driver's window Origin: projectMM, on the [Hue v1 CLIP API](https://developers.meethue.com/develop/hue-api/) -[Tests](../../../tests/unit-tests.md#huedriver) · [.h](../../../../src/light/drivers/HueDriver.h) · [detail](HueDriver.md) +[Tests](../../tests/unit-tests.md#huedriver) + +Detail: [technical](moxygen/HueDriver.md) ## Preview drivers @@ -77,7 +83,9 @@ Streams a true-shape 3D preview to the web UI over WebSocket as a **point list** Origin: projectMM, on [MoonLight](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/PhysicalLayer.h)'s PhysicalLayer model -[Tests](../../../tests/unit-tests.md#previewdriver) · [.h](../../../../src/light/drivers/PreviewDriver.h) · [detail](PreviewDriver.md) +[Tests](../../tests/unit-tests.md#previewdriver) + +Detail: [technical](moxygen/PreviewDriver.md) ## LED output — details @@ -85,8 +93,8 @@ The three LED-output peripherals, compared. All drive WS2812B-class strips with | Peripheral | Chip | Strands | Notes | |------------|------|---------|-------| -| **RMT** ([RmtLedDriver.md](RmtLedDriver.md)) | any ESP32 (classic 8 ch, S3 4, P4 4 DMA) | one per RMT TX channel | the general single-/few-strand output; default for classic + S3 board entries. Adds `loopbackFrame` — a whole-frame variant of the self-test (bit-verifies a full frame, catching frame-rate / RF corruption a 24-bit burst misses). | -| **LCD_CAM** ([LcdLedDriver.md](LcdLedDriver.md)) | ESP32-S3 | **exactly 8** parallel (one DMA transfer) | the S3's scale path where RMT tops out at 4. Adds `clockPin` (10) / `dcPin` (11) — i80 bus lines the LEDs ignore. Give unused lanes `0`. | -| **Parlio** ([ParlioLedDriver.md](ParlioLedDriver.md)) | ESP32-P4 | **1–8** parallel (one DMA transfer) | the P4's parallel path (Parlio generates its own pixel clock — no clock/dc pins). On P4-NANO a known-good set is `20,21,22,23,24,25,26,27`. | +| **RMT** ([RmtLedDriver.md](moxygen/RmtLedDriver.md)) | any ESP32 (classic 8 ch, S3 4, P4 4 DMA) | one per RMT TX channel | the general single-/few-strand output; default for classic + S3 board entries. Adds `loopbackFrame` — a whole-frame variant of the self-test (bit-verifies a full frame, catching frame-rate / RF corruption a 24-bit burst misses). | +| **LCD_CAM** ([LcdLedDriver.md](moxygen/LcdLedDriver.md)) | ESP32-S3 | **exactly 8** parallel (one DMA transfer) | the S3's scale path where RMT tops out at 4. Adds `clockPin` (10) / `dcPin` (11) — i80 bus lines the LEDs ignore. Give unused lanes `0`. | +| **Parlio** ([ParlioLedDriver.md](moxygen/ParlioLedDriver.md)) | ESP32-P4 | **1–8** parallel (one DMA transfer) | the P4's parallel path (Parlio generates its own pixel clock — no clock/dc pins). On P4-NANO a known-good set is `20,21,22,23,24,25,26,27`. | The detail pages carry each peripheral's wire contract, buffer slicing, memory sizing, and the loopback self-test. diff --git a/docs/moonmodules/light/drivers/HueDriver.md b/docs/moonmodules/light/drivers/HueDriver.md deleted file mode 100644 index a6576ceb..00000000 --- a/docs/moonmodules/light/drivers/HueDriver.md +++ /dev/null @@ -1,27 +0,0 @@ -# HueDriver - -Overview, controls, prior art, source, and tests: [drivers.md § Hue](drivers.md#hue). This page carries *only* what no single source file can: what makes Hue different (rate limit, bridge-smoothed transitions, pairing), the Hue v1 HTTP wire contract, and the Devices-module listing. - -![A HueDriver in the UI](../../../assets/light/drivers/Hue%20driver.png) - -## What makes Hue different (and why the driver is shaped this way) - -Hue is an HTTP hub, not a strip, and these properties drive the design: - -- **It's rate-limited** (~10 commands/s across the bridge; true real-time needs the [Hue Entertainment API](https://developers.meethue.com/develop/hue-entertainment/) — DTLS streaming at ~25 Hz, a separate future). So the driver paces itself: **`loop()` does at most one bridge PUT every `kPutIntervalMs`**, gated by a `platform::millis()` check (never work-every-tick), round-robined across the lights. A single bounded ~ms PUT can't stall a frame; the rest of `loop()` returns instantly. This is **smooth ambient colour**, the standard API's sweet spot — not fast strobing. -- **Only colour-capable, reachable lights are driven.** The bridge reports each light's capabilities; the driver keeps only lights whose state has a `hue` field (an "Extended color light") *and* `reachable:true`, so every window pixel maps to a bulb that can actually show the effect's colour right now. A dimmable-only white, an on/off plug, or a powered-off bulb is skipped. -- **Transitions are smoothed by the bridge.** Each PUT carries a `transitiontime` matched to how often that light is refreshed (lights × interval), so the bulb *glides* from its current colour to the next instead of snapping — the bridge's built-in fade, tuned to the cadence. (The Hue default of 400 ms is too long for this rate and smears into a frozen look.) -- **The shared output Correction applies**, same as the physical LED / network drivers: the global brightness slider and a swapped colour-order preset reach the Hue lights too (each pixel runs through the brightness LUT + channel reorder before RGB→HSV). Brightness 0 → black → the light turns off. -- **It needs an app key** (a "username"): the user presses the bridge's physical link button once, then the device claims a key. The driver runs this as a short bounded poll across a few 1 Hz ticks — never blocking the loop waiting for the press. -- **It's plain HTTP, no TLS.** The Hue v1 API answers over `http://<bridge>/api/...`, so there is no certificate handling on the device. Bench-confirmed against a BSB002 bridge (API 1.77). - -![An effect driving the Hue lights](../../../assets/light/drivers/Hue%20friendly%20effect.png) - -## Wire contract (Hue v1 API, plain HTTP) - -The driver talks to the bridge over `platform::httpRequest` (declared in `src/platform/platform.h` — a synchronous LAN HTTP GET/PUT/POST helper that reads the response straight into the caller's buffer): - -- **Pair** — `POST http://<bridgeIp>/api` with `{"devicetype":"projectMM#device"}`. Before the link button is pressed the bridge returns `link button not pressed`; after, `[{"success":{"username":"<key>"}}]` — the `<key>` becomes `appKey`. -- **List lights** — `GET http://<bridgeIp>/api/<appKey>/lights` → a JSON object keyed by light id (`{"1":{…},"2":{…}}`). A real bridge's response runs several KB; the driver scans it for colour-capable (`"hue"` in state) + reachable (`"reachable":true`) lights and maps window index → light id. -- **List rooms** — `GET http://<bridgeIp>/api/<appKey>/groups` → a JSON object keyed by group id (`{"1":{"name":…,"lights":["1","2"],"type":"Room"},…}`). The driver scans it for `"type":"Room"` entries, reads each room's `name` and `lights` id array, and records which colour lights belong to each room (a bitmask over the colour-light list). This feeds the `room`/`light` dropdowns and the driven-set filter. -- **Set a light** — `PUT http://<bridgeIp>/api/<appKey>/lights/<id>/state` with `{"on":true,"bri":0-254,"hue":0-65535,"sat":0-254,"transitiontime":N}` (or `{"on":false,…}` when the pixel is black). `hue`/`sat`/`bri` come from a textbook integer RGB→HSV of the pixel; `transitiontime` (deciseconds) is the cadence-matched fade. diff --git a/docs/moonmodules/light/drivers/LcdLedDriver.md b/docs/moonmodules/light/drivers/LcdLedDriver.md deleted file mode 100644 index 28466fa2..00000000 --- a/docs/moonmodules/light/drivers/LcdLedDriver.md +++ /dev/null @@ -1,21 +0,0 @@ -# LCD LED Driver - -Overview, controls, prior art, source, and tests: [drivers.md § LED output](drivers.md#lcdled). This page carries *only* what no single source file can: the 3-slot-per-bit wire contract, buffer slicing, DMA memory sizing, and cross-domain wiring. - -## Wire contract — 3 slots per bit - -Each WS2812 bit becomes three bus slots at 2.67 MHz (slot = 375 ns): all-active-lanes HIGH, then the data bits, then all LOW — so a `1` is HIGH 750 ns and a `0` 375 ns, approximating the [RMT driver's](RmtLedDriver.md) 700/350 ns timings. The slot is deliberately not the lineage's ~416 ns: newer WS2812B revisions spec T0H max ≈ 380 ns, and a longer `0` pulse on a direct 3.3 V data line gets misread as `1` (the strip washes out white). One 8-bit bus word per slot; bus bit L is the L-th pin of `pins`. Strands of unequal length idle LOW once exhausted (they are dropped from both the pulse-start and data slots — no white flashes on short strands). The ≥300 µs latch is **part of the DMA buffer** — driven zero bytes appended to the frame — so the lines rest actively LOW between frames. The full slot layout lives in [LcdSlots.h](../../../../src/light/drivers/LcdSlots.h). - -Because the whole frame is pre-encoded into one DMA buffer off the hot path and the transfer runs autonomously, **no CPU deadline exists during transmission** — the WiFi-induced bit-slip that plagues refill-based drivers cannot occur by construction. - -## Buffer slicing across pins - -Identical semantics to the [RMT driver](RmtLedDriver.md#buffer-slicing-across-pins): consecutive slices of the source buffer in `pins` order, sizes from `ledsPerPin`, even-split remainder. The parsers are shared (`PinList.h`). - -## Memory - -One internal-RAM DMA frame buffer owned by the platform (PSRAM is deliberately not used — the peripheral streams from internal SRAM): `longest lane × channels × 24 + latch pad` bytes, ~72 B per RGB light. A 1000-light installation across 8 lanes ≈ 9 KB; the documented boundary is ~1500+ lights on a *single* lane (~110 KB), where a future streaming/PSRAM increment takes over. Allocation respects the platform heap reserve and degrades to a status error — never a crash. - -## Cross-domain wiring - -The driver is added as a child of the `Drivers` container at runtime via the catalog (`POST /api/modules`, a board's [`deviceModels.json`](../../../../web-installer/deviceModels.json) `modules` entry) — not boot-wired, so it only exists on a board that selects it. The type is registered on every target, but the peripheral exists only where the SOC has the LCD_CAM i80 bus (the S3 among current targets): on a chip without it the driver is inert (`lanesAvailable()` is 0, so init / loopback report "not supported on this platform"), so a board entry only lists `LcdLedDriver` where it makes sense. Once added, `Drivers::passBufferToDrivers` wires it like any child. The **slot encode** (`LcdSlots.h`) is domain code, host-testable; the **peripheral** (`platform_esp32_lcd.cpp`, ESP-IDF's [`esp_lcd` i80 bus](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-reference/peripherals/lcd/index.html) + GDMA) is the only IDF-touching part. diff --git a/docs/moonmodules/light/drivers/NetworkSendDriver.md b/docs/moonmodules/light/drivers/NetworkSendDriver.md deleted file mode 100644 index fac2240c..00000000 --- a/docs/moonmodules/light/drivers/NetworkSendDriver.md +++ /dev/null @@ -1,32 +0,0 @@ -# Network Send Driver - -Overview, controls, prior art, source, and tests: [drivers.md § Network Send](drivers.md#networksend). This page carries *only* what no single source file can: the per-protocol chunking table, E1.31/Art-Net interop notes, the synchronous-send caveat, and the packet-layout headers. - -![NetworkSendDriver controls](../../../assets/light/drivers/NetworkSendDriver.png) - -## Chunking per protocol - -| Protocol | Port | Chunk | Lights/packet (RGB) | Frame at 128×128 | -|---|---|---|---|---| -| ArtNet | 6454 | 510-channel universes | 170 | 97 packets | -| E1.31 | 5568 | 510-channel universes | 170 | 97 packets | -| DDP | 4048 | 1440-byte chunks, byte offset + push on last | 480 | 35 packets | - -ArtNet and E1.31 split at **510 channels per universe** — whole RGB lights, the xLights/Falcon convention; consecutive universes from `universe_start`. **DDP is the fast path**: per-packet cost dominates the wire time (~280 µs/packet Ethernet, ~1140 µs WiFi), so 480 lights per packet cuts a 128×128 WiFi frame from ~110 ms to ~40 ms. - -E1.31 framing facts an integrator needs: CID is stable per device (derived from the MAC), source name `projectMM`, priority 100, one frame-level sequence stamped on every universe of a frame. The full byte layouts live in [E131Packet.h](../../../../src/light/E131Packet.h), [DdpPacket.h](../../../../src/light/DdpPacket.h) and [ArtNetPacket.h](../../../../src/light/ArtNetPacket.h) — shared with the receiver so the two sides cannot drift. - -## Interop notes - -- **Universe rule (both ends):** buffer offset = (universe − `universe_start`) × 510, and the sender emits from `universe_start` verbatim — no hidden 1-based adjustment for E1.31. Strict sACN gear reserves universe 0, so set `universe_start ≥ 1` on **both** ends when talking to it; the matching default of 0 on our own receiver keeps device↔device pairs aligned out of the box. -- **Unicast or broadcast; not multicast.** The destination can be a single device (unicast) or the limited-broadcast address `255.255.255.255` (the default), which sprays the frame to every device on the LAN — the platform socket sets `SO_BROADCAST` so this works for all three protocols. The standard Art-Net convention is broadcast; a device↔device pair works out of the box with no IP typed. E1.31 multicast (group 239.255.x.x) is not implemented — the platform has no IGMP join; MoonLight ships without it too. - -## Synchronous send (blocks the render tick) - -The whole frame goes out inline in `loop()` — ~35 ms over Ethernet / ~90 ms over WiFi at 128×128 with ArtNet (DDP proportionally less). The dedicated send task that decouples the wire from the render tick is a backlog item gated on PSRAM ([backlog](../../../backlog/README.md)). FPS limiting plus the all-universes-in-one-burst shape is what receivers expect. - -## Cross-domain wiring - -The driver is added as a child of the `Drivers` container at runtime — not boot-wired, but added per board through the catalog (`POST /api/modules`, the board's [`deviceModels.json`](../../../../web-installer/deviceModels.json) `modules` entry), so a device only carries the outputs its board actually has. Once added it receives `setSourceBuffer` / `setCorrection` from `Drivers::passBufferToDrivers` (which wires every child generically, boot-added or runtime-added) and applies the shared `const Correction*` before every send — the same correction the RMT LED driver applies, so network and wired outputs show identical colours. The added child persists across reboot via `FilesystemModule`. - -The live send tier — `uv run moondeck/scenario/run_network_live.py` ([MoonDeck.md § run_network_live](../../../../moondeck/MoonDeck.md#run_network_live)) — relays between real boards with the protocol control cycled round-robin, exercising the wire path no host test reaches. diff --git a/docs/moonmodules/light/drivers/ParlioLedDriver.md b/docs/moonmodules/light/drivers/ParlioLedDriver.md deleted file mode 100644 index c3a77fb6..00000000 --- a/docs/moonmodules/light/drivers/ParlioLedDriver.md +++ /dev/null @@ -1,21 +0,0 @@ -# Parlio LED Driver - -Overview, controls, prior art, source, and tests: [drivers.md § LED output](drivers.md#parlioled). This page carries *only* what no single source file can: the P4 pin budget (all three LED peripherals at once), the shared 3-slot wire contract, memory sizing, and cross-domain wiring. - -## Wire contract — 3 slots per bit - -Identical to the [LCD driver's](LcdLedDriver.md#wire-contract-3-slots-per-bit): each WS2812 bit becomes three bus slots at 2.67 MHz (slot = 375 ns) — all-active-lanes HIGH, the data bits, then all LOW — so a `1` is HIGH 750 ns and a `0` 375 ns. The 375 ns slot (not the lineage's ~416 ns) keeps T0H inside newer WS2812B revisions' ~380 ns window; the P4 Parlio's 160 MHz PLL clock divides to it exactly (÷60). One 8-bit bus word per slot, bus bit L = the L-th pin of `pins`; short strands idle LOW once exhausted. The ≥300 µs latch is zeroed trailing bytes of the DMA buffer, so the lines rest actively LOW between frames. The encoder is **shared with the LCD driver** — a Parlio bus byte and an i80 bus byte are identical — and lives in [LcdSlots.h](../../../../src/light/drivers/LcdSlots.h). - -Because the whole frame is pre-encoded into one DMA buffer off the hot path and the transfer runs autonomously (single-shot, not Parlio's loop-transmission mode), **no CPU deadline exists during transmission** — the WiFi-induced bit-slip of refill-based drivers cannot occur by construction. - -## Buffer slicing across pins - -Identical semantics to the [RMT driver](RmtLedDriver.md#buffer-slicing-across-pins): consecutive slices of the source buffer in `pins` order, sizes from `ledsPerPin`, even-split remainder. The parsers are shared (`PinList.h`). - -## Memory - -One internal-RAM DMA frame buffer owned by the platform (PSRAM is deliberately not used — Parlio streams from internal SRAM): `longest lane × channels × 24 + latch pad` bytes, ~72 B per RGB light, same sizing as the LCD driver. A 1000-light installation across 8 lanes ≈ 9 KB; the ~1500+-lights-on-a-single-lane (~110 KB) boundary where a future streaming/PSRAM increment takes over is the same documented limit. Allocation respects the platform heap reserve and degrades to a status error — never a crash. - -## Cross-domain wiring - -The driver is added as a child of the `Drivers` container at runtime via the catalog (`POST /api/modules`, a board's [`deviceModels.json`](../../../../web-installer/deviceModels.json) `modules` entry) — not boot-wired, so it only exists on a board that selects it. The type is registered on every target, but the peripheral exists only where the SOC has the Parlio TX unit (the P4 among current targets): on a chip without it the driver is inert (`lanesAvailable()` is 0), so a board entry only lists `ParlioLedDriver` where it makes sense. Once added, `Drivers::passBufferToDrivers` wires it like any child. The **slot encode** ([LcdSlots.h](../../../../src/light/drivers/LcdSlots.h), shared) is domain code, host-testable; the **peripheral** (`platform_esp32_parlio.cpp`, ESP-IDF's `esp_driver_parlio` TX unit + DMA) is the only IDF-touching part. diff --git a/docs/moonmodules/light/drivers/PreviewDriver.md b/docs/moonmodules/light/drivers/PreviewDriver.md deleted file mode 100644 index 08969b22..00000000 --- a/docs/moonmodules/light/drivers/PreviewDriver.md +++ /dev/null @@ -1,31 +0,0 @@ -# Preview Driver - -Overview, controls, prior art, source, and tests: [drivers.md § Preview](drivers.md#preview). This page carries *only* what no single source file can: the wire contract and the cross-module data flow. (Implementation mechanics live in `PreviewDriver.h`.) - -## Protocol - -PreviewDriver owns both wire formats and **streams** the bytes to a `BinaryBroadcaster` — the core [HttpServerModule](../../core/moxygen/HttpServerModule.md) — via `beginBinaryFrame`/`pushBinaryFrame`/`endBinaryFrame`, never building a copy of a frame. The HTTP server only writes the bytes to its WebSocket clients: no knowledge of the preview, the light domain, or the formats. `main.cpp` wires the driver's broadcaster to the HTTP server instance. - -The wire layout is the device source itself — edit the header, this follows: - -```cpp ---8<-- "src/light/drivers/PreviewDriver.h:wire-format" -``` - -- **`0x03` coordinate table** — sent on geometry change (LUT rebuild), new-client connect, or a downscale-factor change; not per-frame. `stride` carries the downscale factor (1 = full res). -- **`0x02` per-frame channels** — RGB by coordinate-table order. The browser **skips a `0x02` whose `count` ≠ the current `0x03` count** (a rebuild is mid-flight); the device likewise withholds colour frames until the matching `0x03` is accepted, so the two never desync. - -## Cross-module data flow - -PreviewDriver reads the **sparse driver buffer** — the one the LED/ArtNet drivers also consume — which the `Layer`'s [MappingLUT](../moxygen/MappingLUT.md) fills with exactly the real lights (a radius-4 sphere → 210 entries, not its 9×9×9 = 729 box). Both messages stream straight from that buffer and the layout's coordinate iterator; neither holds a preview-side copy. The coordinate table is built in the **same driver order** as the buffer, so RGB index `i` and coordinate `i` always refer to the same light. See [Layer](../moxygen/Layer.md) / [MappingLUT](../moxygen/MappingLUT.md) for the box→driver mapping. - -**Buffer-lifecycle coupling (the non-obvious one).** A large frame rides HttpServerModule's resumable `sendBufferedFrame`, drained a chunk per tick from the *stable* driver buffer — so a 196² frame (~115 KB) never stalls the loop, and the frame rate self-limits to what the link sustains. But a geometry rebuild frees + reallocs that buffer, so `onBuildState()` must call `cancelBufferedSend()` first, or a half-sent frame reads freed memory — a use-after-free guard pinned by a test. This coupling spans PreviewDriver ↔ HttpServerModule ↔ the Layer buffer, and is why the driver streams without its own frame copy. - -## Large layouts — design rationale - -The preview never freezes or tears at any grid size: it always delivers a **complete** frame, shedding in order — frame rate first, then spatial resolution. - -- **Point cap = min(display, memory).** A browser canvas is a few hundred px wide, so beyond ~4096 points the lights are sub-pixel and more points only cost link bandwidth (a 16K-point full-res frame streams at <1 fps even on Ethernet) — the bottleneck at large grids is *throughput, not memory*. A second cap from `maxAllocBlock()` only bites on a board too tight to stream even the display cap. Above the cap the driver keeps lights on a **spatial lattice** (position ≡ 0 mod `s`), sampling *positions* not indices so there's no diagonal moiré in 2D/3D. -- **Adaptive downscale** — the deeper fallback after frame rate. The struggle signal is **latency** (a frame still draining after several `fps` slots), catching the slow-but-complete case a pure all-sent signal misses. Sustained struggle coarsens the lattice (`stride`++); sustained prompt sends refine back, with hysteresis to stop oscillation. - -Positions are 1 byte/axis; a box exceeding 255 on any axis is scaled (largest edge → 255, aspect preserved), so large grids preview at true proportions. diff --git a/docs/moonmodules/light/drivers/RmtLedDriver.md b/docs/moonmodules/light/drivers/RmtLedDriver.md deleted file mode 100644 index f882ec4e..00000000 --- a/docs/moonmodules/light/drivers/RmtLedDriver.md +++ /dev/null @@ -1,54 +0,0 @@ -# RMT LED Driver - -Overview, controls, prior art, source, and tests: [drivers.md § LED output](drivers.md#rmtled). This page carries *only* what no single source file can: the WS2812B wire contract, buffer slicing, the on-device loopback self-test, and the LED-flicker troubleshooting playbook. - -Output driver for WS2812B-class addressable LEDs over the ESP32 **[RMT (Remote Control Transceiver)](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/rmt.html)** peripheral — one GPIO and one RMT TX channel per strand. Reads the [Drivers](../moxygen/Drivers.md) buffer, applies the shared [output correction](../archive/Drivers.md#output-correction) per light, and emits the WS2812 1-wire signal. - -## Wire contract — [WS2812B](https://cdn-shop.adafruit.com/datasheets/WS2812B.pdf) - -1-wire NRZ at 800 kHz, no clock line. Each data bit is a 1.25 µs cell that starts HIGH then drops LOW; the HIGH duration encodes the bit: - -| | HIGH | period | meaning | -|---|---|---|---| -| `0` bit | 350 ns | 1250 ns | short high, long low | -| `1` bit | 700 ns | 1250 ns | long high, short low | - -Bits are sent **MSB-first** within each byte; channel order (GRB, GRBW, …) is the light preset applied by `Correction` before the encode, so the encoder itself is order-agnostic. Frames are latched by ≥ 300 µs idle-LOW (current WS2812B/SK6812 silicon — the old 50 µs value is dead). These timings live in `LedDriverConfig` and are converted to RMT ticks from the peripheral's granted resolution (≈ 40 MHz / 25 ns per tick), so they are not hard-coded to one clock. - -## Buffer slicing across pins - -The source buffer is split into **consecutive slices**, one per pin, in list order: pin 1 takes lights `[0, n₁)`, pin 2 takes `[n₁, n₁+n₂)`, and so on. Slice sizes come from `ledsPerPin`; pins without an explicit count split the unassigned remainder evenly (the last pin takes the rounding remainder). Counts are clamped so the sum never exceeds the buffer; lights beyond the last slice are not emitted. With `ledsPerPin` empty the whole buffer splits evenly over all pins — the zero-config case. - -Each pin is also capped at a **WS2812 per-pin ceiling** (2048 lights). A single 1-wire WS2812 line clocks a fixed ~30 µs/light, so 2048 lights is already ~61 ms/frame (~16 FPS) — the floor before output turns to a slideshow. A pin over the ceiling is **clamped to it** (the driver still drives the first 2048, output stays lit — it does not idle) and a Warning status is raised ("some LEDs not driven…"), cleared once the count drops back under. This guards the common misconfig — a whole grid funneled onto one pin — which otherwise pins the tick at ~490 ms (FPS 1). The intended way to drive fewer lights is the start/count window, not this safety cap. The cap is a per-protocol value: a clocked 2-wire type (APA102/SK9822 over SPI) has no such fixed-rate limit and would pass a far higher one. (Clamping accumulates offsets from the clamped counts, so on a multi-pin over-ceiling config the later strips show a shifted window, not just a truncated one — accepted as a degraded, warned state for a config no one wires on purpose; the one-pin headline case is exact.) - -## Concurrent show (blocks the render tick for the longest strand) - -`loop()` encodes the whole frame once, then starts every pin's transmission (`platform::rmtWs2812Transmit`) before waiting on each (`platform::rmtWs2812Wait`) — the RMT channels clock out concurrently, so the render tick is charged roughly the **longest** strand, not the sum (~3 ms per 100 pixels on the longest slice), plus one shared reset gap. The transmission runs synchronously on the render task, so a large strand or a WiFi-interrupt-sensitive install can show timing artifacts. - -## Cross-domain wiring - -The driver is added as a child of the `Drivers` container at runtime via the catalog (`POST /api/modules`, a board's [`deviceModels.json`](../../../../web-installer/deviceModels.json) `modules` entry) — not boot-wired, exactly like [NetworkSendDriver](NetworkSendDriver.md). RMT is the default LED driver for classic ESP32 and S3 board entries. The type is registered on every target; on a chip without RMT TX channels it is inert. Once added, it receives `setSourceBuffer` / `setCorrection` / `setLayer` from `Drivers::passBufferToDrivers` (which wires every child, boot- or runtime-added), and applies the same `const Correction*` ArtNet uses. The **symbol encode** (`encodeWs2812Symbols` in `RmtSymbol.h`) is domain code in `src/light/` so it is host-testable; the **peripheral** (`platform::rmtWs2812*` in `src/platform/esp32/platform_esp32_rmt.cpp`) is the only ESP-IDF-touching part. Per-chip channel and memory limits come from the IDF SOC capability macros, so the same code serves classic, S3 and P4. - -The peripheral half uses the [**modern RMT driver**](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/rmt.html) (ESP-IDF 5.x+ "RMT v2": `driver/rmt_tx.h` / `rmt_rx.h` / `rmt_encoder.h` — `rmt_new_tx_channel()`, a copy encoder, `rmt_transmit()`), **not** the legacy channel-numbered API (`driver/rmt.h`, `rmt_config_t`, `RMT_CHANNEL_n`, `rmt_write_items()`). This isn't a preference — the legacy driver was **removed entirely in [ESP-IDF v6](https://docs.espressif.com/projects/esp-idf/en/v6.0/esp32/migration-guides/release-6.x/6.0/peripherals.html)** (the build IDF), so the modern API is the only one that exists. One payoff is portability: the same v2 code serves every RMT-bearing target with no per-chip branching, including the [**P4**](https://www.espressif.com/en/products/socs/esp32-p4), whose RMT additionally has a DMA backend (`SOC_RMT_SUPPORT_DMA`, used by the whole-frame loopback capture — the classic ESP32 has no RMT DMA). - -## Loopback self-test (on device) - -The RMT peripheral is a transceiver, so the driver can verify its own output on real silicon — no separate test firmware. Jumper the **first** pin in `pins` (TX) to `loopbackRxPin`, then tick the `loopbackTest` control: the driver transmits a known WS2812 pattern out the data pin, captures it back on the RX pin, decodes, and compares. To test another output, temporarily move it to the front of the list. The outcome goes to the module's **status field** (`setStatus`): `loopback PASS`, `loopback FAIL: sent … got …`, or `loopback: jumper not detected` (a plain-GPIO continuity pre-check runs first, so a wiring fault is reported as such, not mistaken for a code bug). The test releases **all** TX channels first (so the RX capture can always allocate RMT memory, even with every channel in use) and briefly drives the test pattern, so any strips flicker once during the run; normal output resumes after. All hardware lives in `platform::rmtWs2812Loopback`. - -The default test sends a 24-bit pattern — enough to prove the GPIO emits correct bytes, but blind to faults that only appear over a sustained transfer (frame-rate DMA corruption, RF interference on a long data line — the *intermittent flicker* class of bug). Tick `loopbackFrame` to switch to the whole-frame variant: it transmits a real frame the size of the first pin's slice, back to back like the render loop, captures the **entire** frame, and bit-verifies every WS2812 bit. A single flipped bit anywhere fails the test and the status reports its position (`loopback FAIL: bad bit N/M (light K)`); a clean run reports the bit count (`loopback PASS (M bits)`). Run it while WiFi is active to reproduce interference that only manifests under radio load. Hardware lives in `platform::rmtWs2812LoopbackFrame`. (On the classic ESP32, which has no RMT DMA, the whole-frame capture is capped to one RMT channel's worth of symbols — ~2 RGB lights — and the frame is still clocked back to back; the S3/P4 capture the full frame via DMA.) - -## Troubleshooting: flicker on LEDs that should be off - -Random wrong colours on LEDs that the effect leaves black — most often a few stray pixels flickering — is, on a 3.3 V ESP32 driving WS2812 **directly**, almost always a **data-line signal-integrity** problem, not a firmware bug. WS2812 wants a logic-high near 0.7 × VDD (≈ 3.5 V on a 5 V strip), but the ESP32 only drives 3.3 V, so individual bits sit at the margin and noise tips them. Confirm the firmware is innocent before reaching for the soldering iron — these checks were the actual diagnosis path on the bench (recorded in [decisions.md](../../../history/decisions.md)): - -1. **Is the data clean?** The preview/source buffer is the logical RGB the effect produced — if it shows no stray colour, the effect is innocent (the corruption is downstream of the buffer). -2. **Is the firmware/peripheral clean?** Run the `loopbackFrame` self-test through a short jumper on the data pin. A `PASS` means the RMT encode + transmit emit bit-perfect WS2812 — the GPIO is fine. -3. **Is it WiFi RF?** Lower `Network.txPowerSetting` from 20 dBm down toward 2 and watch. If the flicker shrinks with TX power, it's radio coupling into the data wire (mitigate with the cap below). If it's **unchanged across the whole sweep, it is not the radio** — it's the physical data path. - -When 1–3 all come back clean, the fix is electrical, in rough order of effectiveness: - -- **Add a 3.3 → 5 V level shifter** on the data line (e.g. 74HCT125 / 74AHCT125) — the single most effective fix; it restores the logic-high margin the LEDs expect. -- **Add a ~330 Ω series resistor** at the GPIO, close to the board, to damp reflections. -- **Shorten / shield the data wire**, and keep it away from the power leads and the antenna. -- **Share a solid, thick common ground** between the strip's supply and the board. -- If RF coupling was implicated by step 3, set a per-board `Network.txPowerSetting` cap (the same `deviceModels.json` mechanism the ESP32-S3 N16R8 Dev uses). diff --git a/docs/moonmodules/light/effects/effects.md b/docs/moonmodules/light/effects.md similarity index 78% rename from docs/moonmodules/light/effects/effects.md rename to docs/moonmodules/light/effects.md index 792ec6ee..c7688228 100644 --- a/docs/moonmodules/light/effects/effects.md +++ b/docs/moonmodules/light/effects.md @@ -1,10 +1,10 @@ # Effects -Every effect, one block each: its preview, what it does, and what each control means — together. An effect writes per-pixel colour into its [Layer](../moxygen/Layer.md)'s buffer each tick; [modifiers](../modifiers/modifiers.md) reshape the result and a [driver](../drivers/PreviewDriver.md) sends it out. Effects that name an index colour read the global palette (the `palette` control on [Drivers](../moxygen/Drivers.md)) via `colorFromPalette`. Each block's emoji are its `tags()` (origin/creator/audio — see the [tag emoji legend](../../../architecture.md#tag-emoji-legend)); **Dim** is its native axes ([Layer](../moxygen/Layer.md) extrudes a lower-dim effect onto a bigger grid). Effects are grouped into sections by origin, and each block carries that effect's preview, behaviour, and control descriptions together. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../../backlog/folder-structure-proposal.md).) +Every effect, one block each: its preview, what it does, and what each control means — together. An effect writes per-pixel colour into its [Layer](moxygen/Layer.md)'s buffer each tick; [modifiers](modifiers.md) reshape the result and a [driver](moxygen/PreviewDriver.md) sends it out. Effects that name an index colour read the global palette (the `palette` control on [Drivers](moxygen/Drivers.md)) via `colorFromPalette`. Each block's emoji are its `tags()` (origin/creator/audio — see the [tag emoji legend](../../architecture.md#tag-emoji-legend)); **Dim** is its native axes ([Layer](moxygen/Layer.md) extrudes a lower-dim effect onto a bigger grid). Effects are grouped into sections by origin, and each block carries that effect's preview, behaviour, and control descriptions together. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../backlog/folder-structure-proposal.md).) **Jump to:** [MoonLight](#moonlight-effects) · [MoonModules](#moonmodules-effects) · [WLED](#wled-effects) · [FastLED](#fastled-effects) · [projectMM-native](#projectmm-native-effects) -**Migrating an effect — behaviour is the spec.** A ported effect must reproduce the original's **exact** visual behaviour: end users have relied on these for years, so a port that looks different is a regression, not an improvement. Don't get creative with defaults, oscillator math, colour mapping, or geometry, and don't silently drop a parameter that *is* the mechanism (the PaintBrush straight-vs-curved-lines bug was a dropped partial-line `length`; Game of Life was wrong the first time by not porting the real algorithm). Study the source for the algorithm, defaults, and visual result; pin it with unit + scenario tests; then write our **own** implementation against `EffectBase`/our primitives — carry the behaviour forward, don't trace or copy the structure (see [*Industry standards, our own code*](../../../../CLAUDE.md#principles)). Credit the origin as prior art in the block below. +**Migrating an effect — behaviour is the spec.** A ported effect must reproduce the original's **exact** visual behaviour: end users have relied on these for years, so a port that looks different is a regression, not an improvement. Don't get creative with defaults, oscillator math, colour mapping, or geometry, and don't silently drop a parameter that *is* the mechanism (the PaintBrush straight-vs-curved-lines bug was a dropped partial-line `length`; Game of Life was wrong the first time by not porting the real algorithm). Study the source for the algorithm, defaults, and visual result; pin it with unit + scenario tests; then write our **own** implementation against `EffectBase`/our primitives — carry the behaviour forward, don't trace or copy the structure (see [*Industry standards, our own code*](../../../CLAUDE.md#principles)). Credit the origin as prior art in the block below. > Some WLED-origin effects show a preview gif from [WLED-Utils](https://github.com/scottrbailey/WLED-Utils) by scottrbailey (the canonical WLED effect gif set, cross-linked with credit); these show WLED's rendering. Effects with a local `../../../assets/…` gif show our own output. @@ -19,9 +19,11 @@ Two interfering sine waves beat against each other into a moiré colour field. - `freq_x` / `freq_y` — horizontal/vertical wave frequency (1–8). - `speed` — animation rate (0 = frozen). -Origin: WLED · by ldirko & blazoncek (WLED port) · [gallery](https://editor.soulmatelights.com/gallery/1089-distorsion-waves) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) · source [DistortionWavesEffect.h](../../../../src/light/effects/DistortionWavesEffect.h) +Origin: WLED · by ldirko & blazoncek (WLED port) · [gallery](https://editor.soulmatelights.com/gallery/1089-distorsion-waves) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) -[Tests](../../../tests/unit-tests.md#distortionwaveseffect) +Detail: [technical](moxygen/DistortionWavesEffect.md) + +[Tests](../../tests/unit-tests.md#distortionwaveseffect) <a id="fixedrectangle"></a> @@ -34,9 +36,11 @@ A solid colour filling a positioned box within the grid, with an optional altern - `Rectangle width` / `Rectangle height` / `Rectangle depth` — the box extent on each axis. - `alternateWhite` — alternate box pixels to white in a checker pattern. -Origin: MoonLight · by [limpkin](https://github.com/limpkin) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) · source [FixedRectangleEffect.h](../../../../src/light/effects/FixedRectangleEffect.h) +Origin: MoonLight · by [limpkin](https://github.com/limpkin) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) + +Detail: [technical](moxygen/FixedRectangleEffect.md) -[Tests](../../../tests/unit-tests.md#fixedrectangleeffect) +[Tests](../../tests/unit-tests.md#fixedrectangleeffect) <a id="freqsaws"></a> @@ -52,9 +56,11 @@ Audio-reactive sawtooth waves: each column maps to a frequency band whose magnit - `keepOn` — keep oscillating even when a band is silent. - `method` — phase model (`Chaos`, `Chaos fix`, `BandPhases`). -Origin: MoonLight (audio) · by [@TroyHacks](https://github.com/troyhacks) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) · source [FreqSawsEffect.h](../../../../src/light/effects/FreqSawsEffect.h) +Origin: MoonLight (audio) · by [@TroyHacks](https://github.com/troyhacks) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) + +Detail: [technical](moxygen/FreqSawsEffect.md) -[Tests](../../../tests/unit-tests.md#freqsawseffect) +[Tests](../../tests/unit-tests.md#freqsawseffect) <a id="lavalamp"></a> @@ -68,9 +74,11 @@ Three slow blobs through a black→red→orange→yellow→white ramp — atmosp - `radius` — blob influence radius. - `intensity` — field gain into the black→red→orange→yellow→white ramp. -Origin: projectMM original (metaball lava lamp) · source [LavaLampEffect.h](../../../../src/light/effects/LavaLampEffect.h) +Origin: projectMM original (metaball lava lamp) -[Tests](../../../tests/unit-tests.md#spiraleffect) +Detail: [technical](moxygen/LavaLampEffect.md) + +[Tests](../../tests/unit-tests.md#spiraleffect) <a id="lines"></a> @@ -83,7 +91,9 @@ Sweeps axis-aligned planes in sync; red/green/blue name the X/Y/Z axis — a pre - `speed` — sweep BPM. - `axis` — which plane sweeps (`all`, `x (red)`, `y (green)`, `z (blue)`). -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) · source [LinesEffect.h](../../../../src/light/effects/LinesEffect.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) + +Detail: [technical](moxygen/LinesEffect.md) <a id="metaballs"></a> @@ -98,9 +108,11 @@ Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/bl - `count` — number of orbiting balls (1–8). - `hue_shift` — rotate the palette index. -Origin: projectMM original (metaballs) · source [MetaballsEffect.h](../../../../src/light/effects/MetaballsEffect.h) +Origin: projectMM original (metaballs) -[Tests](../../../tests/unit-tests.md#metaballseffect) +Detail: [technical](moxygen/MetaballsEffect.md) + +[Tests](../../tests/unit-tests.md#metaballseffect) <a id="particles"></a> @@ -115,9 +127,11 @@ A swarm of drifting particles with persistent fading trails. - `fade` — trail persistence (higher = longer tails). - `hue_shift` — rotate every particle's hue. -Origin: MoonLight · by WildCats08 / [@Brandon502](https://github.com/Brandon502) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) · source [ParticlesEffect.h](../../../../src/light/effects/ParticlesEffect.h) +Origin: MoonLight · by WildCats08 / [@Brandon502](https://github.com/Brandon502) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) + +Detail: [technical](moxygen/ParticlesEffect.md) -[Tests](../../../tests/unit-tests.md#particleseffect) +[Tests](../../tests/unit-tests.md#particleseffect) <a id="plasma"></a> @@ -131,9 +145,11 @@ Summed sine waves on orthogonal + diagonal axes; large rolling blobs (3D on volu - `scale_x` / `scale_y` — blob size on each axis (larger = bigger, calmer blobs, lower spatial frequency). - `hue_shift` — rotate the palette index. -Origin: FastLED / WLED lineage (classic plasma) · source [PlasmaEffect.h](../../../../src/light/effects/PlasmaEffect.h) +Origin: FastLED / WLED lineage (classic plasma) + +Detail: [technical](moxygen/PlasmaEffect.md) -[Tests](../../../tests/unit-tests.md#plasmaeffect) +[Tests](../../tests/unit-tests.md#plasmaeffect) <a id="praxis"></a> @@ -144,9 +160,11 @@ An algorithmic palette pattern driven by two beat oscillators (a macro and a mic - `macroMutatorFreq` / `macroMutatorMin` / `macroMutatorMax` — the coarse mutator's beat frequency and its oscillation range. - `microMutatorFreq` / `microMutatorMin` / `microMutatorMax` — the fine mutator's beat frequency and range. -Origin: MoonLight · by MONSOONO / @Flavourdynamics · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) · source [PraxisEffect.h](../../../../src/light/effects/PraxisEffect.h) +Origin: MoonLight · by MONSOONO / @Flavourdynamics · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) -[Tests](../../../tests/unit-tests.md#praxiseffect) +Detail: [technical](moxygen/PraxisEffect.md) + +[Tests](../../tests/unit-tests.md#praxiseffect) <a id="rainbow"></a> @@ -158,9 +176,11 @@ Diagonal animated rainbow — always-visible default/test effect. - `speed` — animation BPM (one full hue cycle per beat). -Origin: FastLED · Mark Kriegsman (rainbow) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_FastLED.h) · source [RainbowEffect.h](../../../../src/light/effects/RainbowEffect.h) +Origin: FastLED · Mark Kriegsman (rainbow) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_FastLED.h) + +Detail: [technical](moxygen/RainbowEffect.md) -[Tests](../../../tests/unit-tests.md#rainboweffect) +[Tests](../../tests/unit-tests.md#rainboweffect) <a id="random"></a> @@ -170,9 +190,11 @@ Lights one random light per frame in a random palette colour over a fading backg - `fade` — how fast prior sparkles fade to black. -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) · source [RandomEffect.h](../../../../src/light/effects/RandomEffect.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) -[Tests](../../../tests/unit-tests.md#randomeffect) +Detail: [technical](moxygen/RandomEffect.md) + +[Tests](../../tests/unit-tests.md#randomeffect) <a id="rings"></a> @@ -187,9 +209,11 @@ Expanding concentric rings from random centres, additive overlap (calm defaults) - `thickness` — ring band width. - `hue_shift` — rotate every ring's hue. -Origin: projectMM original (concentric rings) · source [RingsEffect.h](../../../../src/light/effects/RingsEffect.h) +Origin: projectMM original (concentric rings) + +Detail: [technical](moxygen/RingsEffect.md) -[Tests](../../../tests/unit-tests.md#spiraleffect) +[Tests](../../tests/unit-tests.md#spiraleffect) <a id="ripples"></a> @@ -202,9 +226,11 @@ Distance-from-centre sets a per-column wave phase; the lit surface ripples like - `speed` — wave animation rate (0 = frozen, 99 = fast). - `interval` — wavefront spacing (low = tight rings, high = wide). -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) · source [RipplesEffect.h](../../../../src/light/effects/RipplesEffect.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) + +Detail: [technical](moxygen/RipplesEffect.md) -[Tests](../../../tests/unit-tests.md#spiraleffect) +[Tests](../../tests/unit-tests.md#spiraleffect) <a id="rubikscube"></a> @@ -217,9 +243,11 @@ A 3D Rubik's Cube projected onto the volume: it scrambles, then plays its soluti - `randomTurning` — turn endlessly at random instead of scramble-then-solve. - `usePalette` — colour the six faces from the system-wide palette instead of the classic Rubik's colours. -Origin: MoonLight · by WildCats08 / [@Brandon502](https://github.com/Brandon502) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) · source [RubiksCubeEffect.h](../../../../src/light/effects/RubiksCubeEffect.h) +Origin: MoonLight · by WildCats08 / [@Brandon502](https://github.com/Brandon502) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) -[Tests](../../../tests/unit-tests.md#rubikscubeeffect) +Detail: [technical](moxygen/RubiksCubeEffect.md) + +[Tests](../../tests/unit-tests.md#rubikscubeeffect) <a id="solid"></a> @@ -233,9 +261,11 @@ A flat fill with five colour modes: a plain RGB(W) colour, the active palette sp - `minRGB` — in the band modes, drops palette entries whose every channel is below this floor. - `randomColors` — in the band modes, deterministically shuffles the surviving palette entries. -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) · source [SolidEffect.h](../../../../src/light/effects/SolidEffect.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) + +Detail: [technical](moxygen/SolidEffect.md) -[Tests](../../../tests/unit-tests.md#solideffect) +[Tests](../../tests/unit-tests.md#solideffect) <a id="spheremove"></a> @@ -245,9 +275,11 @@ A hollow spherical shell that bounces through the 3D volume, its surface coloure - `speed` — how fast the sphere moves through the volume. -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) · source [SphereMoveEffect.h](../../../../src/light/effects/SphereMoveEffect.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) -[Tests](../../../tests/unit-tests.md#spheremoveeffect) +Detail: [technical](moxygen/SphereMoveEffect.md) + +[Tests](../../tests/unit-tests.md#spheremoveeffect) <a id="spiral"></a> @@ -261,9 +293,11 @@ Rotating spiral from angle + distance (`atan2_8`/`dist8`). - `twist` — how tightly the arm winds (hue gain per unit of distance). - `hue_shift` — rotate the palette index. -Origin: projectMM original (rotating spiral) · source [SpiralEffect.h](../../../../src/light/effects/SpiralEffect.h) +Origin: projectMM original (rotating spiral) + +Detail: [technical](moxygen/SpiralEffect.md) -[Tests](../../../tests/unit-tests.md#spiraleffect) +[Tests](../../tests/unit-tests.md#spiraleffect) <a id="starfield"></a> @@ -276,9 +310,11 @@ A perspective starfield: stars approach the viewer from a vanishing point, brigh - `blur` — motion-trail fade per frame. - `usePalette` — colour the stars from the palette instead of white. -Origin: MoonLight · by [@Brandon502](https://github.com/Brandon502), inspired by Daniel Shiffman / [Coding Train](https://www.youtube.com/watch?v=17WoOqgXsRM) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) · source [StarFieldEffect.h](../../../../src/light/effects/StarFieldEffect.h) +Origin: MoonLight · by [@Brandon502](https://github.com/Brandon502), inspired by Daniel Shiffman / [Coding Train](https://www.youtube.com/watch?v=17WoOqgXsRM) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) + +Detail: [technical](moxygen/StarFieldEffect.md) -[Tests](../../../tests/unit-tests.md#starfieldeffect) +[Tests](../../tests/unit-tests.md#starfieldeffect) <a id="starsky"></a> @@ -292,9 +328,11 @@ Twinkling stars at random light positions, each fading in and out independently - `star_fill_ratio` — how many stars (as a fraction of the light count). - `usePalette` — colour the stars from the active palette instead of white. -Origin: MoonLight · by [limpkin](https://github.com/limpkin) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) · source [StarSkyEffect.h](../../../../src/light/effects/StarSkyEffect.h) +Origin: MoonLight · by [limpkin](https://github.com/limpkin) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) -[Tests](../../../tests/unit-tests.md#starskyeffect) +Detail: [technical](moxygen/StarSkyEffect.md) + +[Tests](../../tests/unit-tests.md#starskyeffect) <a id="text"></a> @@ -308,9 +346,11 @@ Renders a multi-line string in a bitmap font. Static by default (laid out top-le - `speed` — marquee speed (only used when `scroll` is on). - `hue` — palette index for the text colour. -Origin: projectMM original, on MoonLight's Scrolling Text · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) · source [TextEffect.h](../../../../src/light/effects/TextEffect.h) +Origin: projectMM original, on MoonLight's Scrolling Text · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) + +Detail: [technical](moxygen/TextEffect.md) -[Tests](../../../tests/unit-tests.md#texteffect) +[Tests](../../tests/unit-tests.md#texteffect) ## MoonModules effects @@ -332,9 +372,11 @@ Conway's cellular automaton generalised to 2D/3D: selectable rulesets (+ custom - `infinite` — respawn on stasis (R-pentomino/glider) instead of resetting. - `blur` — dead-cell fade strength toward the background colour. -Origin: MoonModules · by Ewoud Wijma (2022), mods by Brandon Butler / [@Brandon502](https://github.com/Brandon502) · [natureofcode](https://natureofcode.com/book/chapter-7-cellular-automata/) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h) · source [GameOfLifeEffect.h](../../../../src/light/effects/GameOfLifeEffect.h) +Origin: MoonModules · by Ewoud Wijma (2022), mods by Brandon Butler / [@Brandon502](https://github.com/Brandon502) · [natureofcode](https://natureofcode.com/book/chapter-7-cellular-automata/) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h) -[Tests](../../../tests/unit-tests.md#gameoflifeeffect) +Detail: [technical](moxygen/GameOfLifeEffect.md) + +[Tests](../../tests/unit-tests.md#gameoflifeeffect) <a id="geq"></a> @@ -349,9 +391,11 @@ A flat graphic equaliser: the 16 audio bands rise as vertical bars from the bott - `colorBars` — colour each bar from the palette by band instead of by row. - `smoothBars` — blend neighbouring bands for smoother bar heights. -Origin: WLED (audio) · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) · source [GEQEffect.h](../../../../src/light/effects/GEQEffect.h) +Origin: WLED (audio) · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) + +Detail: [technical](moxygen/GEQEffect.md) -[Tests](../../../tests/unit-tests.md#geqeffect) +[Tests](../../tests/unit-tests.md#geqeffect) <a id="geq3d"></a> @@ -366,9 +410,11 @@ A 3D-perspective graphic equaliser: audio bands rise as bars with faked depth, t - `numBands` — bands shown (2–16, fewer = wider bars). - `borders` — outline each bar. -Origin: MoonModules (audio) · by [@TroyHacks](https://github.com/troyhacks) (GPLv3) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h) · source [GEQ3DEffect.h](../../../../src/light/effects/GEQ3DEffect.h) +Origin: MoonModules (audio) · by [@TroyHacks](https://github.com/troyhacks) (GPLv3) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h) + +Detail: [technical](moxygen/GEQ3DEffect.md) -[Tests](../../../tests/unit-tests.md#geq3deffect) +[Tests](../../tests/unit-tests.md#geq3deffect) <a id="noise2d"></a> @@ -379,9 +425,11 @@ A smoothly drifting value-noise field: each pixel samples 3D noise (grid positio - `speed` — how fast the field morphs (time-flow rate). - `scale` — noise zoom (higher = finer, more detailed). -Origin: WLED · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) · source [Noise2DEffect.h](../../../../src/light/effects/Noise2DEffect.h) +Origin: WLED · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) -[Tests](../../../tests/unit-tests.md#noise2deffect) +Detail: [technical](moxygen/Noise2DEffect.md) + +[Tests](../../tests/unit-tests.md#noise2deffect) <a id="paintbrush"></a> @@ -396,9 +444,11 @@ Audio-reactive brush strokes: lines whose 3D endpoints oscillate on the beat (`b - `color_chaos` — per-line random hue vs a per-band gradient. - `phase_chaos` — random per-frame phase jitter. -Origin: MoonModules (audio) · by [@TroyHacks](https://github.com/troyhacks) (GPLv3) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h) · source [PaintBrushEffect.h](../../../../src/light/effects/PaintBrushEffect.h) +Origin: MoonModules (audio) · by [@TroyHacks](https://github.com/troyhacks) (GPLv3) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h) + +Detail: [technical](moxygen/PaintBrushEffect.md) -[Tests](../../../tests/unit-tests.md#paintbrusheffect) +[Tests](../../tests/unit-tests.md#paintbrusheffect) <a id="tetrix"></a> @@ -410,9 +460,11 @@ Falling Tetris-style blocks: each column drops a brick that lands on the growing - `width` — brick height (0 = randomised). - `oneColor` — one advancing palette colour for all bricks instead of random per-brick colours. -Origin: WLED · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) · source [TetrixEffect.h](../../../../src/light/effects/TetrixEffect.h) +Origin: WLED · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) -[Tests](../../../tests/unit-tests.md#tetrixeffect) +Detail: [technical](moxygen/TetrixEffect.md) + +[Tests](../../tests/unit-tests.md#tetrixeffect) ## WLED effects @@ -429,9 +481,11 @@ Audio-reactive blurred dots: one frequency band per frame lights a dot whose pos - `freqMap` — place the dot by the major-peak frequency instead of scanning bands. - `geqScanner` — scan the dot across the strip in a GEQ-like sweep. -Origin: WLED (audio) · by Andrew Tuline (WLED-SR), enhancements by [@softhack007](https://github.com/softhack007) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) · source [BlurzEffect.h](../../../../src/light/effects/BlurzEffect.h) +Origin: WLED (audio) · by Andrew Tuline (WLED-SR), enhancements by [@softhack007](https://github.com/softhack007) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) + +Detail: [technical](moxygen/BlurzEffect.md) -[Tests](../../../tests/unit-tests.md#blurzeffect) +[Tests](../../tests/unit-tests.md#blurzeffect) <a id="bouncingballs"></a> @@ -444,9 +498,11 @@ A row of balls per column bounce under gravity, each losing energy on impact and - `grav` — gravity strength (higher = faster fall, snappier bounce). - `numBalls` — balls per column (1–16). -Origin: WLED · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) · source [BouncingBallsEffect.h](../../../../src/light/effects/BouncingBallsEffect.h) +Origin: WLED · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) + +Detail: [technical](moxygen/BouncingBallsEffect.md) -[Tests](../../../tests/unit-tests.md#bouncingballseffect) +[Tests](../../tests/unit-tests.md#bouncingballseffect) <a id="freqmatrix"></a> @@ -462,9 +518,11 @@ A 1D scrolling frequency display: each frame shifts the strip and injects a new - `sensitivity` — input gain (10–100). - `audioSpeed` — let the volume modulate the scroll speed. -Origin: WLED (audio) · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) · source [FreqMatrixEffect.h](../../../../src/light/effects/FreqMatrixEffect.h) +Origin: WLED (audio) · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) -[Tests](../../../tests/unit-tests.md#freqmatrixeffect) +Detail: [technical](moxygen/FreqMatrixEffect.md) + +[Tests](../../tests/unit-tests.md#freqmatrixeffect) <a id="lissajous"></a> @@ -478,9 +536,11 @@ A Lissajous curve traced across the grid from two phase-shifted `sin8`/`cos8` sw - `fadeRate` — trail fade per frame. - `speed` — how fast the curve's phase advances. -Origin: WLED · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) · source [LissajousEffect.h](../../../../src/light/effects/LissajousEffect.h) +Origin: WLED · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) + +Detail: [technical](moxygen/LissajousEffect.md) -[Tests](../../../tests/unit-tests.md#lissajouseffect) +[Tests](../../tests/unit-tests.md#lissajouseffect) <a id="noisemeter"></a> @@ -493,9 +553,11 @@ An audio VU meter rendered as a noise bar: the volume sets how many rows light f - `fadeRate` — trail decay per frame (200–254). - `width` — how strongly the volume drives the bar height. -Origin: WLED (audio) · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) · source [NoiseMeterEffect.h](../../../../src/light/effects/NoiseMeterEffect.h) +Origin: WLED (audio) · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) -[Tests](../../../tests/unit-tests.md#noisemetereffect) +Detail: [technical](moxygen/NoiseMeterEffect.md) + +[Tests](../../tests/unit-tests.md#noisemetereffect) <a id="wave"></a> @@ -507,9 +569,11 @@ An oscilloscope waveform scrolls across the grid with a fading trail; six select - `fade` — trail fade per frame (0 = instant clear, 255 = long tail). - `type` — waveform shape (`Sawtooth`, `Triangle`, `Sine`, `Square`, `Sin3`, `Noise`). -Origin: MoonLight · by Ewoud Wijma · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) · source [WaveEffect.h](../../../../src/light/effects/WaveEffect.h) +Origin: MoonLight · by Ewoud Wijma · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) + +Detail: [technical](moxygen/WaveEffect.md) -[Tests](../../../tests/unit-tests.md#waveeffect) +[Tests](../../tests/unit-tests.md#waveeffect) ## FastLED effects @@ -526,9 +590,11 @@ Fire2012-style heat field — sparks at the base rise and cool through the activ The flame colour comes from the **active palette**. For the classic fire look pick the **Lava** palette (black→red→orange→yellow→white — the recommended default); any palette works, so an Ocean or Forest palette turns the flame blue or green. -Origin: FastLED / MoonLight · Mark Kriegsman's Fire2012; MoonLight adapts [MatrixFireFast](https://github.com/toggledbits/MatrixFireFast) (toggledbits) · source [FireEffect.h](../../../../src/light/effects/FireEffect.h) +Origin: FastLED / MoonLight · Mark Kriegsman's Fire2012; MoonLight adapts [MatrixFireFast](https://github.com/toggledbits/MatrixFireFast) (toggledbits) + +Detail: [technical](moxygen/FireEffect.md) -[Tests](../../../tests/unit-tests.md#fireeffect) +[Tests](../../tests/unit-tests.md#fireeffect) <a id="noise"></a> @@ -541,9 +607,11 @@ Smooth animated value noise; true 3D field on volumetric layouts. - `scale` — spatial frequency of the field (1–32, higher = finer detail). - `bpm` — scroll speed (8 noise cells per beat). -Origin: FastLED · inoise field (Mark Kriegsman) · source [NoiseEffect.h](../../../../src/light/effects/NoiseEffect.h) +Origin: FastLED · inoise field (Mark Kriegsman) -[Tests](../../../tests/unit-tests.md#noiseeffect) +Detail: [technical](moxygen/NoiseEffect.md) + +[Tests](../../tests/unit-tests.md#noiseeffect) ## projectMM-native effects @@ -555,9 +623,11 @@ The 16 mic frequency bands spread across X, each column lit bottom-up by its mag - `colorMode` — bar colouring: `height` (green base → red top, the VU look) or `per-band` (each column its own hue, the rainbow analyser look). -Origin: projectMM original, on the WLED-SR GEQ / spectrum concept (Andrew Tuline) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) · source [AudioSpectrumEffect.h](../../../../src/light/effects/AudioSpectrumEffect.h) +Origin: projectMM original, on the WLED-SR GEQ / spectrum concept (Andrew Tuline) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) + +Detail: [technical](moxygen/AudioSpectrumEffect.md) -[Tests](../../../tests/unit-tests.md#audiomodule) +[Tests](../../tests/unit-tests.md#audiomodule) <a id="audiovolume"></a> @@ -567,24 +637,28 @@ A whole-grid VU meter: every light pulses with the mic level, colour indexing th - `brightness` — overall brightness ceiling for the VU pulse (1–255). -Origin: projectMM original (VU meter) · source [AudioVolumeEffect.h](../../../../src/light/effects/AudioVolumeEffect.h) +Origin: projectMM original (VU meter) -[Tests](../../../tests/unit-tests.md#audiomodule) +Detail: [technical](moxygen/AudioVolumeEffect.md) + +[Tests](../../tests/unit-tests.md#audiomodule) <a id="demoreel"></a> ### DemoReel 🎬 · 3D -A demo reel: plays every other registered effect in turn, auto-advancing on a timer, so one Layer cycles the whole library hands-free — the showcase/test tool for everything. It hosts a single live effect at a time (created from the effect registry, rendered into this Layer) and swaps to the next when the interval elapses — new effects are picked up automatically. It can also pick a fresh palette each cycle and overlay the playing effect's name. The `status` line shows which effect is playing (e.g. `playing: Plasma (3/20)`). It never hosts itself, and it plays effects in sequence rather than compositing them (layering is the [Layer](../moxygen/Layer.md) stack's job). +A demo reel: plays every other registered effect in turn, auto-advancing on a timer, so one Layer cycles the whole library hands-free — the showcase/test tool for everything. It hosts a single live effect at a time (created from the effect registry, rendered into this Layer) and swaps to the next when the interval elapses — new effects are picked up automatically. It can also pick a fresh palette each cycle and overlay the playing effect's name. The `status` line shows which effect is playing (e.g. `playing: Plasma (3/20)`). It never hosts itself, and it plays effects in sequence rather than compositing them (layering is the [Layer](moxygen/Layer.md) stack's job). - `interval` — seconds each effect plays before advancing (1–120). - `shuffle` — jump to a random next effect instead of registry order. - `randomPalette` — pick a random palette on each cycle (showcases the palette set); default on. - `showName` — overlay the playing effect's name in a small font; default on. -Origin: FastLED · Mark Kriegsman's [DemoReel100](https://github.com/FastLED/FastLED/blob/master/examples/DemoReel100/DemoReel100.ino); projectMM reel · source [DemoReelEffect.h](../../../../src/light/effects/DemoReelEffect.h) +Origin: FastLED · Mark Kriegsman's [DemoReel100](https://github.com/FastLED/FastLED/blob/master/examples/DemoReel100/DemoReel100.ino); projectMM reel + +Detail: [technical](moxygen/DemoReelEffect.md) -[Tests](../../../tests/unit-tests.md#demoreeleffect) +[Tests](../../tests/unit-tests.md#demoreeleffect) <a id="networkreceive"></a> @@ -595,11 +669,13 @@ Receives lights-over-UDP (Art-Net, E1.31/sACN, DDP) and writes it into the layer - `universe_start` — the first incoming universe to map onto the layer (mirrors the sender). - `channels_per_universe` — bytes each universe maps to (510 = whole RGB lights per universe, the xLights/Falcon convention; 512 for Madrix-style senders that pack pixels across universe boundaries). -Origin: projectMM original (E1.31 / Art-Net receive) · source [NetworkReceiveEffect.h](../../../../src/light/effects/NetworkReceiveEffect.h) +Origin: projectMM original (E1.31 / Art-Net receive) -[Tests](../../../tests/unit-tests.md#networkreceiveeffect) +Detail: [technical](moxygen/NetworkReceiveEffect.md) -**Wire contract:** listens for [Art-Net](https://art-net.org.uk/downloads/art-net.pdf), [E1.31 / sACN](https://tsp.esta.org/tsp/documents/docs/ANSI_E1-31-2018.pdf), and [DDP](http://www.3waylabs.com/ddp/) simultaneously; `universe_start` + `channels_per_universe` map incoming universes onto the layer buffer. The end-to-end pair with [NetworkSendDriver](../drivers/NetworkSendDriver.md). +[Tests](../../tests/unit-tests.md#networkreceiveeffect) + +**Wire contract:** listens for [Art-Net](https://art-net.org.uk/downloads/art-net.pdf), [E1.31 / sACN](https://tsp.esta.org/tsp/documents/docs/ANSI_E1-31-2018.pdf), and [DDP](http://www.3waylabs.com/ddp/) simultaneously; `universe_start` + `channels_per_universe` map incoming universes onto the layer buffer. The end-to-end pair with [NetworkSendDriver](moxygen/NetworkSendDriver.md). <a id="sine"></a> @@ -611,7 +687,9 @@ R/G/B each follow a sine along one axis at 120° phase offset — a glowing, scr - `amplitude` — peak brightness (0–255, 255 = full). - `bpm` — scroll speed. -Origin: MoonLight (Sinus, AI-generated) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) · source [SineEffect.h](../../../../src/light/effects/SineEffect.h) +Origin: MoonLight (Sinus, AI-generated) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) + +Detail: [technical](moxygen/SineEffect.md) -[Tests](../../../tests/unit-tests.md#sineeffect) +[Tests](../../tests/unit-tests.md#sineeffect) diff --git a/docs/moonmodules/light/layouts/layouts.md b/docs/moonmodules/light/layouts.md similarity index 78% rename from docs/moonmodules/light/layouts/layouts.md rename to docs/moonmodules/light/layouts.md index a38ff5e4..0ef16619 100644 --- a/docs/moonmodules/light/layouts/layouts.md +++ b/docs/moonmodules/light/layouts.md @@ -1,6 +1,6 @@ # Layouts -Every layout, one block each: what it does and what each control means — together. A layout maps light indices to physical `(x, y, z)` positions — it defines the *shape* an [effect](../effects/effects.md) draws onto and a [driver](../drivers/drivers.md) sends out. The [Layouts](../moxygen/Layouts.md) container holds one or more layout children and composes them into one coordinate space; a [Layer](../moxygen/Layer.md) renders over that combined space. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../../backlog/folder-structure-proposal.md).) +Every layout, one block each: what it does and what each control means — together. A layout maps light indices to physical `(x, y, z)` positions — it defines the *shape* an [effect](effects.md) draws onto and a [driver](drivers.md) sends out. The [Layouts](moxygen/Layouts.md) container holds one or more layout children and composes them into one coordinate space; a [Layer](moxygen/Layer.md) renders over that combined space. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../backlog/folder-structure-proposal.md).) ## MoonLight layouts @@ -14,7 +14,9 @@ A pair of concentric-ring "headlight" clusters (nested rings of 1/8/12/16/24 LED - `scale` — overall size scale (1–10). -Origin: projectMM / custom fixture · source [CarLightsLayout.h](../../../../src/light/layouts/CarLightsLayout.h) +Origin: projectMM / custom fixture + +Detail: [technical](moxygen/CarLightsLayout.md) <a id="cube"></a> @@ -27,7 +29,9 @@ A 3D cube volume, `width`×`height`×`depth`, wired in a configurable axis order - `X++` / `Y++` / `Z++` — count up (vs down) along that axis. - `snakeX` / `snakeY` / `snakeZ` — serpentine (alternate rows/columns reverse) on that axis. -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) · source [CubeLayout.h](../../../../src/light/layouts/CubeLayout.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) + +Detail: [technical](moxygen/CubeLayout.md) <a id="humansizedcube"></a> @@ -37,7 +41,9 @@ A hollow walk-in cube built from five LED-curtain faces (front, back, top, left, - `width` / `height` / `depth` — cube extent per axis (1–20). -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) · source [HumanSizedCubeLayout.h](../../../../src/light/layouts/HumanSizedCubeLayout.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) + +Detail: [technical](moxygen/HumanSizedCubeLayout.md) <a id="panel"></a> @@ -50,7 +56,9 @@ A 2D matrix panel with full wiring control: choose the axis order, per-axis dire - `X++` / `Y++` — count up vs down along that axis. - `snake` — serpentine wiring (alternate lines reverse). -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) · source [PanelLayout.h](../../../../src/light/layouts/PanelLayout.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) + +Detail: [technical](moxygen/PanelLayout.md) <a id="panels"></a> @@ -63,7 +71,9 @@ Tiles an M×N grid of full matrix panels into one large display: an outer walk o - `panelWidth` / `panelHeight` — each panel's size (1–512). - `wiringOrder` / `X++` / `Y++` / `snake` — the per-panel light wiring. -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) · source [PanelsLayout.h](../../../../src/light/layouts/PanelsLayout.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) + +Detail: [technical](moxygen/PanelsLayout.md) <a id="ring"></a> @@ -77,7 +87,9 @@ A single ring of LEDs evenly spaced around a circle — `nrOfLEDs` points, start - `clockwise` — direction of travel. - `scale` — spacing/radius scale. -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) · source [RingLayout.h](../../../../src/light/layouts/RingLayout.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) + +Detail: [technical](moxygen/RingLayout.md) <a id="rings241"></a> @@ -87,7 +99,9 @@ The classic 241-LED concentric-ring disc: nested rings of 1, 8, 12, 16, 24, 32, - `scale` — overall radius scale (1–10). -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) · source [Rings241Layout.h](../../../../src/light/layouts/Rings241Layout.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) + +Detail: [technical](moxygen/Rings241Layout.md) <a id="singlecolumn"></a> @@ -100,7 +114,9 @@ A vertical line of LEDs at a fixed X — the 1D column primitive. - `X position` — the column's x. - `reversed order` — wire top-to-bottom instead of bottom-to-top. -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) · source [SingleColumnLayout.h](../../../../src/light/layouts/SingleColumnLayout.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) + +Detail: [technical](moxygen/SingleColumnLayout.md) <a id="singlerow"></a> @@ -113,7 +129,9 @@ A horizontal line of LEDs at a fixed Y — the 1D row primitive. - `Y position` — the row's y. - `reversed order` — wire right-to-left instead of left-to-right. -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) · source [SingleRowLayout.h](../../../../src/light/layouts/SingleRowLayout.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) + +Detail: [technical](moxygen/SingleRowLayout.md) <a id="spiral"></a> @@ -125,7 +143,9 @@ A conical spiral: `ledCount` LEDs winding up a cone from `bottomRadius` to a poi - `bottomRadius` — radius at the base. - `height` — spiral height. -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) · source [SpiralLayout.h](../../../../src/light/layouts/SpiralLayout.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) + +Detail: [technical](moxygen/SpiralLayout.md) <a id="torontobargourds"></a> @@ -138,7 +158,9 @@ Maps a set of decorative "gourd" objects (a specific bar installation), each ren - `granularity` — `One Gourd One Light`, `One Side One Light`, or `One LED One Light`. - `nrOfLightsPerGourd` — LEDs per gourd in the coarsest mode (1–128). -Origin: projectMM / custom fixture · source [TorontoBarGourdsLayout.h](../../../../src/light/layouts/TorontoBarGourdsLayout.h) +Origin: projectMM / custom fixture + +Detail: [technical](moxygen/TorontoBarGourdsLayout.md) <a id="tubes"></a> @@ -151,7 +173,9 @@ Parallel vertical tubes: `nrOfTubes` columns of `ledsPerTube` LEDs, spaced `tube - `tubeDistance` — spacing between tubes. - `reversed` — reverse the wiring order. -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) · source [TubesLayout.h](../../../../src/light/layouts/TubesLayout.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) + +Detail: [technical](moxygen/TubesLayout.md) ## projectMM-native layouts @@ -164,9 +188,11 @@ A dense 3D grid, row-major (x fastest, then y, then z); every position maps to a - `width` / `height` / `depth` — grid extent on each axis in lights (1–512). - `serpentine` — boustrophedon-wire alternate rows (every other row runs in reverse, matching a snaked strip). -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) · source [GridLayout.h](../../../../src/light/layouts/GridLayout.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) -[Tests](../../../tests/unit-tests.md#gridlayout) +Detail: [technical](moxygen/GridLayout.md) + +[Tests](../../tests/unit-tests.md#gridlayout) <a id="sphere"></a> @@ -176,9 +202,11 @@ Lights on the surface of a hollow sphere — a one-light-thick shell inside a `( - `radius` — surface radius in light-units (1–64); the shell is every cell whose distance from the centre rounds to `radius`. -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) · source [SphereLayout.h](../../../../src/light/layouts/SphereLayout.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) + +Detail: [technical](moxygen/SphereLayout.md) -[Tests](../../../tests/unit-tests.md#spherelayout) +[Tests](../../tests/unit-tests.md#spherelayout) <a id="wheel"></a> @@ -189,9 +217,11 @@ A bicycle-wheel: `spokes` straight rows radiate from a centre hub, each carrying - `spokes` — number of spokes radiating from the hub (2–64). - `ledsPerSpoke` — LEDs along each spoke, spaced one unit apart from the centre outward. -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) · source [WheelLayout.h](../../../../src/light/layouts/WheelLayout.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) + +Detail: [technical](moxygen/WheelLayout.md) -[Tests](../../../tests/unit-tests.md#wheellayout) +[Tests](../../tests/unit-tests.md#wheellayout) -The [Layouts](../moxygen/Layouts.md) container itself takes no controls — see its page for coordinate iteration, reordering, and rebuild propagation. +The [Layouts](moxygen/Layouts.md) container itself takes no controls — see its page for coordinate iteration, reordering, and rebuild propagation. diff --git a/docs/moonmodules/light/modifiers/modifiers.md b/docs/moonmodules/light/modifiers.md similarity index 70% rename from docs/moonmodules/light/modifiers/modifiers.md rename to docs/moonmodules/light/modifiers.md index 5de50e1f..ea858b67 100644 --- a/docs/moonmodules/light/modifiers/modifiers.md +++ b/docs/moonmodules/light/modifiers.md @@ -1,6 +1,6 @@ # Modifiers -Every modifier, one block each: its preview, what it does, and what each control means — together. A modifier sits between an [effect](../effects/effects.md) and the output: it reshapes *where* pixels land (or masks them) without changing the effect's drawing. Modifiers compose — a [Layer](../moxygen/Layer.md) folds its whole modifier stack each rebuild; a *dynamic* modifier (one that overrides `modifyLive`) also runs a per-frame pass. See [ModifierBase](../moxygen/ModifierBase.md) for the static-vs-dynamic split. Each block's emoji are its `tags()` (see the [tag emoji legend](../../../architecture.md#tag-emoji-legend)); **Kind** is static (baked into the mapping at rebuild) or dynamic (per-frame remap). Modifiers are grouped into sections, and each block carries that modifier's preview, behaviour, and control descriptions together. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../../backlog/folder-structure-proposal.md).) +Every modifier, one block each: its preview, what it does, and what each control means — together. A modifier sits between an [effect](effects.md) and the output: it reshapes *where* pixels land (or masks them) without changing the effect's drawing. Modifiers compose — a [Layer](moxygen/Layer.md) folds its whole modifier stack each rebuild; a *dynamic* modifier (one that overrides `modifyLive`) also runs a per-frame pass. See [ModifierBase](moxygen/ModifierBase.md) for the static-vs-dynamic split. Each block's emoji are its `tags()` (see the [tag emoji legend](../../architecture.md#tag-emoji-legend)); **Kind** is static (baked into the mapping at rebuild) or dynamic (per-frame remap). Modifiers are grouped into sections, and each block carries that modifier's preview, behaviour, and control descriptions together. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../backlog/folder-structure-proposal.md).) ## MoonLight modifiers @@ -10,9 +10,11 @@ Every modifier, one block each: its preview, what it does, and what each control Expands a 1D effect into concentric **square rings** (Chebyshev distance from the centre): the effect's linear position becomes the ring index, so a gradient effect draws nested squares. -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) · source [BlockModifier.h](../../../../src/light/modifiers/BlockModifier.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) -[Tests](../../../tests/unit-tests.md#blockmodifier) +Detail: [technical](moxygen/BlockModifier.md) + +[Tests](../../tests/unit-tests.md#blockmodifier) <a id="checkerboard"></a> @@ -25,9 +27,11 @@ Masks the layer in a checkerboard: "off" squares are dropped, "on" squares pass - `size` — checker square edge in lights (≥1). - `invert` — flip which squares pass through vs are masked. -Origin: MoonLight · by WildCats08 / [@Brandon502](https://github.com/Brandon502) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) · source [CheckerboardModifier.h](../../../../src/light/modifiers/CheckerboardModifier.h) +Origin: MoonLight · by WildCats08 / [@Brandon502](https://github.com/Brandon502) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) + +Detail: [technical](moxygen/CheckerboardModifier.md) -[Tests](../../../tests/unit-tests.md#checkerboardmodifier) +[Tests](../../tests/unit-tests.md#checkerboardmodifier) <a id="circle"></a> @@ -35,9 +39,11 @@ Origin: MoonLight · by WildCats08 / [@Brandon502](https://github.com/Brandon502 Expands a 1D effect into concentric **circular rings** (Euclidean distance from the centre): the effect's linear position becomes the radius, so a gradient effect draws nested circles. The circular counterpart to [Block](#block). -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) · source [CircleModifier.h](../../../../src/light/modifiers/CircleModifier.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) + +Detail: [technical](moxygen/CircleModifier.md) -[Tests](../../../tests/unit-tests.md#circlemodifier) +[Tests](../../tests/unit-tests.md#circlemodifier) <a id="mirror"></a> @@ -47,9 +53,11 @@ Folds the far half of the box back onto the near half per axis, mirroring the im - `mirrorX` / `mirrorY` / `mirrorZ` — mirror across the centre on that axis (each default on; enabling an axis the layout doesn't use is a no-op). -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) · source [MirrorModifier.h](../../../../src/light/modifiers/MirrorModifier.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) -[Tests](../../../tests/unit-tests.md#mirrormodifier) +Detail: [technical](moxygen/MirrorModifier.md) + +[Tests](../../tests/unit-tests.md#mirrormodifier) <a id="multiply"></a> @@ -62,9 +70,11 @@ Tiles the logical image across the box `multiply` times per axis, optionally mir - `multiplyX` / `multiplyY` / `multiplyZ` — tile count per axis (1–64; 1 = no tiling). - `mirrorX` / `mirrorY` / `mirrorZ` — reflect alternate tiles on that axis (with a count of 2, folds the axis in half — the kaleidoscope mirror). -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) · source [MultiplyModifier.h](../../../../src/light/modifiers/MultiplyModifier.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) + +Detail: [technical](moxygen/MultiplyModifier.md) -[Tests](../../../tests/unit-tests.md#multiplymodifier) +[Tests](../../tests/unit-tests.md#multiplymodifier) <a id="pinwheel"></a> @@ -78,9 +88,11 @@ Remaps the grid into radial **petals** around the centre — the angle to each p - `symmetry` — fold the petals into a factor-of-360 symmetry. - `zTwist` — twist the petals along z (3D). -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) · source [PinwheelModifier.h](../../../../src/light/modifiers/PinwheelModifier.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) + +Detail: [technical](moxygen/PinwheelModifier.md) -[Tests](../../../tests/unit-tests.md#pinwheelmodifier) +[Tests](../../tests/unit-tests.md#pinwheelmodifier) <a id="ripplexz"></a> @@ -91,9 +103,11 @@ Collapses an axis to a single plane so a higher-dimensional effect ripples along - `shrink` — collapse the selected axis (on = collapse). - `towardsX` / `towardsZ` — which axis collapses to a single line. -Origin: MoonLight · by @Troy (WLEDMM Art-Net) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) · source [RippleXZModifier.h](../../../../src/light/modifiers/RippleXZModifier.h) +Origin: MoonLight · by @Troy (WLEDMM Art-Net) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) -[Tests](../../../tests/unit-tests.md#ripplexzmodifier) +Detail: [technical](moxygen/RippleXZModifier.md) + +[Tests](../../tests/unit-tests.md#ripplexzmodifier) <a id="transpose"></a> @@ -104,9 +118,11 @@ Swaps a pair of box axes (and every coordinate through them), then optionally in - `XY` / `XZ` / `YZ` — swap that pair of axes. - `inverse X` / `inverse Y` / `inverse Z` — flip that axis after the swap. -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) · source [TransposeModifier.h](../../../../src/light/modifiers/TransposeModifier.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) + +Detail: [technical](moxygen/TransposeModifier.md) -[Tests](../../../tests/unit-tests.md#transposemodifier) +[Tests](../../tests/unit-tests.md#transposemodifier) ## projectMM-native modifiers @@ -118,9 +134,11 @@ Remaps every light to another via a true 1:1 permutation, reshuffling to a fresh - `bpm` — reshuffles per minute (0–60; 6 ≈ a fresh permutation every 10 s; 0 = frozen). -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) · source [RandomMapModifier.h](../../../../src/light/modifiers/RandomMapModifier.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) + +Detail: [technical](moxygen/RandomMapModifier.md) -[Tests](../../../tests/unit-tests.md#randommapmodifier) +[Tests](../../tests/unit-tests.md#randommapmodifier) <a id="region"></a> @@ -130,9 +148,11 @@ Carves the layer to a sub-rectangle given as percentages of the physical extent - `startX` / `startY` / `startZ` and `endX` / `endY` / `endZ` — the sub-rectangle bounds as **percentages** of each axis's physical extent (0 = start of axis, 100 = end), so the region survives a resize; values may go negative or past 100 to push the window off-screen. -Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) · source [RegionModifier.h](../../../../src/light/modifiers/RegionModifier.h) +Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) -[Tests](../../../tests/unit-tests.md#regionmodifier) +Detail: [technical](moxygen/RegionModifier.md) + +[Tests](../../tests/unit-tests.md#regionmodifier) <a id="rotate"></a> @@ -142,7 +162,9 @@ Rotates the 2D image around its centre, turning continuously over time (the code - `speed` — rotation speed (1–255; turns faster as it rises). -Origin: MoonLight · by WildCats08 / [@Brandon502](https://github.com/Brandon502) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) · source [RotateModifier.h](../../../../src/light/modifiers/RotateModifier.h) +Origin: MoonLight · by WildCats08 / [@Brandon502](https://github.com/Brandon502) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) + +Detail: [technical](moxygen/RotateModifier.md) -[Tests](../../../tests/unit-tests.md#rotatemodifier) +[Tests](../../tests/unit-tests.md#rotatemodifier) diff --git a/docs/moonmodules/light/supporting.md b/docs/moonmodules/light/supporting.md new file mode 100644 index 00000000..5cac1bb0 --- /dev/null +++ b/docs/moonmodules/light/supporting.md @@ -0,0 +1,104 @@ +# Light supporting modules + +The light-domain machinery the catalog modules (effects, modifiers, layouts, drivers) lean on — not directly user-facing. Every row links to its generated technical page (the full API, from the `.h`) and its tests. Cross-cutting rationale that no single `.h` owns lives in the prose sections below the table. + +<a id="layer"></a> + +### Layer + +One rendering layer — an effect writes into its buffer, modifiers transform the coordinate mapping, and the layer composites onto the shared output. The unit the render loop iterates. + +<img src="../../../assets/light/Layer.png" width="300" alt="Layer container with a child effect"> + +- `blendMode` — how this layer composites onto the ones below (overwrite / alpha / additive). + +Detail: [technical](moxygen/Layer.md) + +[Tests](../../tests/unit-tests.md#layer) + +<a id="layers"></a> + +### Layers + +The container of layers — composites them (blend mode + opacity per layer) into the final light buffer. + +<img src="../../../assets/light/Layers.png" width="300" alt="Layers container"> + +Detail: [technical](moxygen/Layers.md) + +[Tests](../../tests/unit-tests.md#layers) + +<a id="layouts"></a> + +### Layouts + +The container of layout modules — walks each layout's coordinates to build the physical light set the mapping consumes. + +<img src="../../../assets/light/Layouts.png" width="300" alt="Layouts container"> + +Detail: [technical](moxygen/Layouts.md) + +[Tests](../../tests/unit-tests.md#layouts) + +<a id="drivers"></a> + +### Drivers + +The container of driver modules — owns the shared driver buffer and the per-light output correction every driver applies before sending. + +<img src="../../../assets/light/Drivers.png" width="300" alt="Drivers container with the on/off + brightness controls"> + +- `on` — master power (default on). Turning it off scales the whole output to black while preserving `brightness`, so on restores the exact level. The single power control every consumer drives (the UI toggle, IR's on/off, the WLED app / Home Assistant, MQTT/Homebridge) through `Scheduler::setControl`. +- `brightness` — global output brightness (0–255). +- `lightPreset` — the output-correction preset (colour order, gamma, brightness) every driver applies. +- `palette` — the active palette effects sample from. + +Detail: [technical](moxygen/Drivers.md) + +[Tests](../../tests/unit-tests.md#drivers) + +### Buffer + +Contiguous light-data buffer, shared between the layers that write it (effects) and the driver groups that read it. A raw `uint8_t*` so any channel layout fits — RGB, RGBW, multi-channel DMX. + +Detail: [technical](moxygen/Buffer.md) + +[Tests](../../tests/unit-tests.md#buffer) + +### MappingLUT + +Maps the virtual grid to the physical sparse light set — a radius-4 sphere becomes its 210 real lights, not the 729-cell box. The lookup effects and the preview both consume. + +Detail: [technical](moxygen/MappingLUT.md) + +[Tests](../../tests/unit-tests.md#mappinglut) + +### Effect base + +The `EffectBase` class every effect derives from — the shared surface (buffer access, dimensions, the palette) an effect renders against. + +Detail: [technical](moxygen/EffectBase.md) + +### Modifier base + +The `ModifierBase` class every modifier derives from — transforms the coordinate mapping (mirror, rotate, multiply, …) a layer applies before rendering. + +Detail: [technical](moxygen/ModifierBase.md) + +### Driver base + +The `DriverBase` class every driver derives from — the shared surface (the driver window, the source buffer, the output correction) a driver reads before sending its slice. It plays the same zero-state role for drivers that Effect base does for effects. + +Detail: [technical](moxygen/DriverBase.md) + +### Layout base + +The `LayoutBase` class every layout derives from — the shared surface a layout implements to walk its coordinates into the physical light set the mapping consumes. + +Detail: [technical](moxygen/LayoutBase.md) + +### Parallel LED driver base + +The `ParallelLedDriver` CRTP base shared by the two parallel WS2812 drivers (the S3's LCD_CAM and the P4's Parlio) — the one copy of the common body they were ~250 lines of byte-for-byte identical over: pin slicing, the fused correct+encode, the latch pad, and the single-shot autonomous-DMA transfer. Each derived driver supplies only its peripheral-specific pieces. + +Detail: [technical](moxygen/ParallelLedDriver.md) diff --git a/docs/moonmodules/light/supporting/supporting.md b/docs/moonmodules/light/supporting/supporting.md deleted file mode 100644 index 8cae5501..00000000 --- a/docs/moonmodules/light/supporting/supporting.md +++ /dev/null @@ -1,68 +0,0 @@ -# Light supporting modules - -The light-domain machinery the catalog modules (effects, modifiers, layouts, drivers) lean on — not directly user-facing. Every row links to its generated technical page (the full API, from the `.h`) and its tests. Cross-cutting rationale that no single `.h` owns lives in the prose sections below the table. - -### Layer - -One rendering layer — an effect writes into its buffer, modifiers transform the coordinate mapping, and the layer composites onto the shared output. The unit the render loop iterates. - -- `blendMode` — how this layer composites onto the ones below (overwrite / alpha / additive). - -Detail: [technical](../moxygen/Layer.md) - -[Tests](../../../tests/unit-tests.md#layer) - -### Layers - -The container of layers — composites them (blend mode + opacity per layer) into the final light buffer. - -Detail: [technical](../moxygen/Layers.md) - -[Tests](../../../tests/unit-tests.md#layers) - -### Layouts - -The container of layout modules — walks each layout's coordinates to build the physical light set the mapping consumes. - -Detail: [technical](../moxygen/Layouts.md) - -[Tests](../../../tests/unit-tests.md#layouts) - -### Drivers - -The container of driver modules — owns the shared driver buffer and the per-light output correction every driver applies before sending. - -- `lightPreset` — the output-correction preset (colour order, gamma, brightness) every driver applies. -- `palette` — the active palette effects sample from. - -Detail: [technical](../moxygen/Drivers.md) - -[Tests](../../../tests/unit-tests.md#drivers) - -### Buffer - -Contiguous light-data buffer, shared between the layers that write it (effects) and the driver groups that read it. A raw `uint8_t*` so any channel layout fits — RGB, RGBW, multi-channel DMX. - -Detail: [technical](../moxygen/Buffer.md) - -[Tests](../../../tests/unit-tests.md#buffer) - -### MappingLUT - -Maps the virtual grid to the physical sparse light set — a radius-4 sphere becomes its 210 real lights, not the 729-cell box. The lookup effects and the preview both consume. - -Detail: [technical](../moxygen/MappingLUT.md) - -[Tests](../../../tests/unit-tests.md#mappinglut) - -### Effect base - -The `EffectBase` class every effect derives from — the shared surface (buffer access, dimensions, the palette) an effect renders against. - -Detail: [technical](../moxygen/EffectBase.md) - -### Modifier base - -The `ModifierBase` class every modifier derives from — transforms the coordinate mapping (mirror, rotate, multiply, …) a layer applies before rendering. - -Detail: [technical](../moxygen/ModifierBase.md) diff --git a/docs/usecases/home-automation.md b/docs/usecases/home-automation.md new file mode 100644 index 00000000..a3a56bbc --- /dev/null +++ b/docs/usecases/home-automation.md @@ -0,0 +1,151 @@ +# Home automation + +Bring a projectMM device into your smart home — controlled alongside your lights, scenes, and automations — using the [MQTT module](../moonmodules/core/services.md#mqtt) (or, for some hubs, the built-in WLED compatibility). The device exposes on/off, brightness, and colour; a home-automation platform adopts it and drives those from its own app, voice assistant, and automations. + +Integration goes **both directions**, and this page covers both: + +- **Your smart home controlling the device** — a hub (Homebridge, Home Assistant, …) adopts the projectMM device and drives its on/off, brightness, and colour from its own app, voice assistant, and automations. +- **The device controlling smart-home lights** — projectMM drives **Philips Hue** bulbs as effect pixels, so your existing smart bulbs become part of a show. + +The recipes below are each self-contained: + +- **[Homebridge (Apple Home / HomeKit)](#homebridge-apple-home-homekit)** — bridge the device over MQTT so it appears as a HomeKit accessory. The full walkthrough. +- **[Drive Hue lights](#drive-hue-lights)** — point an effect at your Hue bulbs. +- **[Other platforms](#other-platforms)** — how Home Assistant and other MQTT hubs reach the same device. + +The control surface these integrations drive — the MQTT controls, topics, and accessory config — is documented once in [Core › Services › MQTT](../moonmodules/core/services.md#mqtt); this page is the setup, and links there for the reference. + +## Prerequisites + +- A projectMM device on your WiFi/Ethernet with the [MQTT module](../moonmodules/core/services.md#mqtt) present (it ships on boards whose catalog entry includes it; the Shelly model does). +- The device's **MAC suffix** — the last 6 hex of its MAC, which is its stable topic id (`projectMM/<suffix>`). Read it from the MQTT module's `mqtt_status`, or once the device is talking to the broker, `mosquitto_sub -t 'projectMM/#'`. The examples below use `563cfe`; substitute yours. + +## Homebridge (Apple Home / HomeKit) + +[Homebridge](https://homebridge.io) exposes non-HomeKit devices to Apple Home. The chain is the same however you host it: an **MQTT broker** sits in the middle, because Homebridge's `homebridge-mqttthing` plugin is an MQTT *client*, not a broker. The device publishes/subscribes to the broker; Homebridge talks to the same broker; HomeKit talks to Homebridge. + +``` +Apple Home ──HAP──▶ Homebridge ──MQTT──▶ broker ◀──MQTT── projectMM device +``` + +So there are two things to do: **install** a broker + Homebridge (the only part that differs by host — a Pi for a permanent setup, a Mac/PC for a quick test), then **configure** the device and the accessory (identical everywhere). If you already run Homebridge and a broker, skip straight to [Set up Homebridge](#set-up-homebridge). + +### Install a broker + Homebridge + +Only this part is host-specific — pick your platform. Each installs **Mosquitto** (the broker) and **Homebridge**; the next section wires them up the same way regardless. A **Raspberry Pi** is the natural permanent home (services survive a reboot); a **Mac or PC** is fine for a throwaway test. Already have both running? Jump to [Set up Homebridge](#set-up-homebridge). + +Wherever the broker runs, the device reaches it over the LAN, so the broker must listen on all interfaces, not just loopback — noted per platform below. Confirm the broker sees the device at any point with: + +``` +mosquitto_sub -t 'projectMM/#' -v +``` + +#### Raspberry Pi + +On Raspberry Pi OS (or any Debian-based distro): + +```bash +# broker +sudo apt update && sudo apt install -y mosquitto mosquitto-clients +``` + +The package starts a `mosquitto` **systemd service**. Mosquitto 2.x listens only on `localhost` by default, so open the LAN listener with a drop-in config — create `/etc/mosquitto/conf.d/projectmm.conf` containing: + +``` +listener 1883 0.0.0.0 +allow_anonymous true +``` + +then `sudo systemctl restart mosquitto`. (`allow_anonymous true` is fine for a home LAN; add a password file if the broker is exposed more widely.) + +Install Homebridge via the official Debian repository ([the apt-package guide](https://github.com/homebridge/homebridge/wiki/Install-Homebridge-on-Raspbian)) — it runs as a systemd service with the Config UI at `http://<pi-ip>:8581`. Both services persist across reboots by default (`sudo systemctl enable mosquitto homebridge` to be explicit), which is what makes a Pi the right permanent host. + +#### macOS + +Mosquitto 2.x binds only to `localhost` by default, so open the LAN listener with a one-line config (`test.conf` with `listener 1883 0.0.0.0` and `allow_anonymous true`), then run it in the foreground so you see every packet (Ctrl-C to stop): + +```bash +brew install mosquitto +printf 'listener 1883 0.0.0.0\nallow_anonymous true\n' > test.conf +"$(brew --prefix)/sbin/mosquitto" -v -c test.conf +``` + +Now the device reaches the broker at your Mac's LAN IP while Homebridge reaches it on loopback. (Allow `mosquitto` through the macOS firewall on `1883` if prompted.) + +Install Homebridge ([homebridge.io](https://homebridge.io) has the macOS installer; `npm i -g homebridge homebridge-config-ui-x` is the CLI route). Start it with `homebridge` in a terminal, or via the menu-bar app. + +**Tearing it down:** Ctrl-C the foreground `mosquitto` and quit Homebridge — nothing persists as a service. + +#### Windows + +```powershell +# broker +winget install EclipseFoundation.Mosquitto +``` + +The installer registers a Windows service and installs the tools under `C:\Program Files\mosquitto\`. For a test, stop the service and run it in the foreground — Mosquitto 2.x binds only to `localhost` unless told otherwise, so pass a one-line config that opens the LAN listener (create `test.conf` with `listener 1883 0.0.0.0` and `allow_anonymous true`): + +```powershell +net stop mosquitto +& 'C:\Program Files\mosquitto\mosquitto.exe' -v -c test.conf +``` + +Allow `mosquitto.exe` through Windows Defender Firewall on `1883` the first time (accept the prompt, or add an inbound rule for TCP 1883). + +Install Homebridge for Windows ([the official installer](https://github.com/homebridge/homebridge/wiki/Install-Homebridge-on-Windows-10-11)) — it runs as a service with the Config UI at `http://localhost:8581`. + +**Tearing it down:** Ctrl-C the foreground `mosquitto` (leave the broker service stopped, or disable it in **Services**); stop the Homebridge service from the Config UI or **Services**. + +### Set up Homebridge + +With a broker and Homebridge running — anywhere — the rest is host-independent: point the device at the broker, then add one accessory to Homebridge. Do this whether you just installed both above or already had them. + +1. **Point the device at the broker.** In the device's web UI, open the **MQTT** module and set: + - `broker` — the broker's hostname or IP. Use the broker host's **LAN** IP (or `.local` name) so the device can route to it — not `127.0.0.1`, which is loopback on the *host*, unreachable from the device. Find it with `hostname -I` (Pi/Linux), `ipconfig getifaddr en0` (macOS), or `ipconfig` (Windows). + - `port` — usually `1883`. + - `username` / `password` — only if your broker requires auth (the test brokers above don't). + + `mqtt_status` turns to `connected` once it reaches the broker, and `mosquitto_sub -t 'projectMM/#' -v` shows the device's `.../on/get`, `.../brightness/get`, `.../hsv/get`, and retained `.../name`. + +2. **Add the accessory to Homebridge.** Install the [`homebridge-mqttthing`](https://github.com/arachnetech/homebridge-mqttthing) plugin (Config UI **Plugins** screen, or `npm i -g homebridge-mqttthing`), then add the `lightbulb` accessory block from [Core › Services › MQTT § Homebridge](../moonmodules/core/services.md#mqtt) to your Homebridge config — via the Config UI **Config** editor, or `~/.homebridge/config.json` for a CLI install. Set: + - `url` — `mqtt://<broker>:1883`. When Homebridge and the broker share a host (the usual case), `mqtt://127.0.0.1:1883` works, since here loopback *is* the broker. + - `563cfe` — replaced by your device's MAC suffix throughout the `topics`. + - `username` / `password` — matching the broker, or removed if none. + + Restart Homebridge. + +### Pair it in Apple Home + +Once the broker, device, and Homebridge are all talking (the `mosquitto_sub` window shows the device, and Homebridge lists the accessory), add it to HomeKit: in the **Home** app, **Add Accessory → More options →** the Homebridge bridge, using the PIN Homebridge prints on start (or shown in the Config UI). The device shows up as a light — on/off, brightness, and the colour wheel that steps through palettes — the same control surface a real hub gives, verified locally. + +## Drive Hue lights + +The other direction: instead of a hub controlling the device, the **device controls your Philips Hue bulbs**, treating each colour bulb as a pixel of an effect. Your existing smart lights join the show — an effect's colours glide across them alongside (or instead of) an LED strip. This is a projectMM **output driver**, not a hub integration, so there's no broker and no Homebridge — the device talks straight to the Hue bridge over its LAN HTTP API. + +Because Hue is a rate-limited HTTP hub (~10 commands/s), this is **smooth ambient colour**, not fast strobing — the driver paces itself to the bridge and lets it fade between colours. Full behaviour, controls, and the wire contract are in the driver reference: [Drivers › Hue](../moonmodules/light/drivers.md#hue). + +**Recommended layout:** set up a **one-dimensional grid — width 1, height = the number of Hue lights** you want to control. Each pixel of that column maps to one bulb (the driver assigns window pixels to bulbs in order), so a 1×N grid gives you exactly N discrete lights with no wasted pixels, and 1D effects (rainbow, chase, …) read naturally across the bulbs. Sizing the grid to your bulb count keeps the effect and the driver in step. + +To set it up: + +1. **Add a Hue driver.** In the device's web UI pipeline (**Layers → a Layer → its Drivers**), add a **Hue** driver. Enter your bridge's IP in `bridgeIp` (find it in the Hue app, or at [discovery.meethue.com](https://discovery.meethue.com)). +2. **Pair with the bridge.** Press the physical **link button** on the Hue bridge, then click the driver's **`pair`** button within ~30 seconds. The device claims an app key (stored on the driver as `appKey`) — a one-time step; the status line reports `paired, N lights`. +3. **Pick what it drives.** The driver lists the bridge's colour-capable, reachable bulbs and its rooms; use the `room` / `light` controls to aim the effect at all bulbs, one room, or a single light. Each selected bulb becomes one pixel of the driver's window. +4. **Run an effect.** Any effect on the layer now drives the bulbs — the global brightness slider and colour-order correction apply to them just like a physical strip (brightness 0 turns a bulb off). + +*Note:* a bulb is only driven if it's an "Extended color light" and currently reachable — a white-only bulb, a plug, or a powered-off light is skipped. For true real-time (fast) Hue, the [Hue Entertainment API](https://developers.meethue.com/develop/hue-entertainment/) (DTLS streaming) is a separate future path; today's driver targets the standard API's ambient-colour sweet spot. + +## Other platforms + +Homebridge above is the worked example; other home-automation platforms adopt the same device through the same primitives. The building blocks are shared — the [MQTT control surface](../moonmodules/core/services.md#mqtt) (broker + the `on`/`brightness`/`hsv` topics) and, for hubs that speak it, the device's WLED compatibility — so a new integration is a new front-end on top, not new device work. + +- **Home Assistant** — HA adopts the device **without MQTT**: its built-in WLED integration discovers it over the WLED `/json` API the device already serves (the same interface the WLED apps use), so on/off + brightness work with no broker in the middle. For the full control surface, HA's MQTT Discovery reaches the same [control topics](../moonmodules/core/services.md#mqtt) below. +- **Others** (Node-RED, openHAB, voice assistants via a hub, …) — anything that speaks MQTT drives the device through the [control topics](../moonmodules/core/services.md#mqtt); the broker setup is the same [install step](#install-a-broker-homebridge) as above, only the consumer differs. + +Each integration is a self-contained recipe; the shared MQTT reference lives once in [Core › Services › MQTT](../moonmodules/core/services.md#mqtt). + +## Troubleshooting + +- **`mqtt_status` stuck on `connecting`** — the device can't reach the broker. Check the broker IP is your computer's *LAN* IP (not `127.0.0.1`, which the device can't route to), that the broker is actually listening on all interfaces (on Windows, the `listener 1883 0.0.0.0` line above), and that the firewall isn't blocking `1883`. +- **Homebridge shows "No Response"** — the accessory's topics don't match the device's MAC suffix, or the `url` points at the wrong broker. Confirm the suffix with `mosquitto_sub -t 'projectMM/#'` and that the same broker appears in both the device's `broker` control and the accessory `url`. +- **Colour wheel doesn't match a specific colour** — expected: HomeKit sends a full-precision hue, and the device snaps it to the *nearest* built-in palette (there's no arbitrary-colour mode). See the palette note in the [MQTT reference](../moonmodules/core/services.md#mqtt). diff --git a/docs/usecases/led-signal-integrity.md b/docs/usecases/led-signal-integrity.md new file mode 100644 index 00000000..55b9a96e --- /dev/null +++ b/docs/usecases/led-signal-integrity.md @@ -0,0 +1,17 @@ +# LED signal integrity — flicker on LEDs that should be off + +Random wrong colours on LEDs the effect leaves black — most often a few stray pixels flickering — is, on a 3.3 V ESP32 driving WS2812 **directly**, almost always a **data-line signal-integrity** problem, not a firmware bug. WS2812 wants a logic-high near 0.7 × VDD (≈ 3.5 V on a 5 V strip), but the ESP32 drives only 3.3 V, so individual bits sit at the margin and noise tips them. + +Confirm the firmware is innocent **before** reaching for the soldering iron. These checks were the actual diagnosis path on the bench (recorded in [decisions.md](../history/decisions.md)): + +1. **Is the data clean?** The preview/source buffer is the logical RGB the effect produced — if it shows no stray colour, the effect is innocent (the corruption is downstream of the buffer). +2. **Is the firmware/peripheral clean?** Run the [`loopbackFrame` self-test](../moonmodules/light/drivers.md#led-output-drivers) through a short jumper on the data pin. A `PASS` means the RMT encode + transmit emit bit-perfect WS2812 — the GPIO is fine. +3. **Is it WiFi RF?** Lower `Network.txPowerSetting` from 20 dBm down toward 2 and watch. If the flicker shrinks with TX power, it's radio coupling into the data wire (mitigate with the level shifter below). If it's **unchanged across the whole sweep, it is not the radio** — it's the physical data path. + +When 1–3 all come back clean, the fix is electrical, in rough order of effectiveness: + +- **Add a 3.3 → 5 V level shifter** on the data line (e.g. 74HCT125 / 74AHCT125) — the single most effective fix; it restores the logic-high margin the LEDs expect. +- **Add a ~330 Ω series resistor** at the GPIO, close to the board, to damp reflections. +- **Shorten / shield the data wire**, and keep it away from the power leads and the antenna. +- **Share a solid, thick common ground** between the strip's supply and the board. +- If RF coupling was implicated by step 3, set a per-board `Network.txPowerSetting` cap (the same `deviceModels.json` mechanism the ESP32-S3 N16R8 Dev uses). diff --git a/mkdocs.yml b/mkdocs.yml index 16305f4f..ec264261 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -122,16 +122,20 @@ nav: # site-relative /projectMM/install/) so MkDocs treats it as the external # resource it is, rather than warning about an unresolvable absolute path. - Web installer: https://moonmodules.org/projectMM/install/ + - Use cases: + - Home automation: usecases/home-automation.md + - LED signal integrity: usecases/led-signal-integrity.md - Effects & building shows: - - Effects: moonmodules/light/effects/effects.md - - Layouts: moonmodules/light/layouts/layouts.md - - Modifiers: moonmodules/light/modifiers/modifiers.md - - Drivers: moonmodules/light/drivers/drivers.md - - Live scripting: moonmodules/light/moonlive/MoonLiveEffect.md - - Supporting: moonmodules/light/supporting/supporting.md + - Effects: moonmodules/light/effects.md + - Layouts: moonmodules/light/layouts.md + - Modifiers: moonmodules/light/modifiers.md + - Drivers: moonmodules/light/drivers.md + - Live scripting: moonmodules/light/MoonLiveEffect.md + - Supporting: moonmodules/light/supporting.md - Core: - - UI: moonmodules/core/ui/ui.md - - Supporting: moonmodules/core/supporting/supporting.md + - Services: moonmodules/core/services.md + - Supporting: moonmodules/core/supporting.md + - Web UI: moonmodules/core/ui.md - Tests (what we verify): - Unit tests: tests/unit-tests.md - Scenario tests: tests/scenario-tests.md diff --git a/moondeck/MoonDeck.md b/moondeck/MoonDeck.md index d7ac2870..63ba9933 100644 --- a/moondeck/MoonDeck.md +++ b/moondeck/MoonDeck.md @@ -231,7 +231,7 @@ For a full description of each scenario, see the [scenario inventory](/api/docs/ ### run_network_live -End-to-end lights-over-UDP matrix test across every online board in moondeck.json's active network — the live proof for [NetworkReceiveEffect](../docs/moonmodules/light/effects/NetworkReceiveEffect.md) and [NetworkSendDriver](../docs/moonmodules/light/drivers/NetworkSendDriver.md). Each round one device is the sender and every other device listens: the PC seeds the sender **three times — once per protocol (ArtNet, E1.31, DDP), each with its own colour** — asserting the sender's `/ws` preview stream shows each one, then points the sender's own NetworkSendDriver at each listener with the protocol control cycled round-robin and asserts the listener's preview shows the sender's corrected colour (brightness + channel order replicated host-side). With one device online only the PC→device sweep runs. +End-to-end lights-over-UDP matrix test across every online board in moondeck.json's active network — the live proof for [NetworkReceiveEffect](../docs/moonmodules/light/effects.md#networkreceive) and [NetworkSendDriver](../docs/moonmodules/light/drivers.md#networksend). Each round one device is the sender and every other device listens: the PC seeds the sender **three times — once per protocol (ArtNet, E1.31, DDP), each with its own colour** — asserting the sender's `/ws` preview stream shows each one, then points the sender's own NetworkSendDriver at each listener with the protocol control cycled round-robin and asserts the listener's preview shows the sender's corrected colour (brightness + channel order replicated host-side). With one device online only the PC→device sweep runs. ```bash uv run moondeck/scenario/run_network_live.py # full matrix over all online devices @@ -254,7 +254,7 @@ Captures and restores each device's grid and removes the temporary NetworkReceiv ### preview_health -Browser-faithful **3D-preview stream health probe** — measures the device's `/ws` preview the way a real browser tab experiences it, so the numbers match what a person watching the [PreviewDriver](../docs/moonmodules/light/drivers/PreviewDriver.md) preview sees. A plain one-shot WebSocket reader gives up the moment the device closes the socket, so it reports stalls a browser never shows (the browser reconnects) and misses the brief blips a browser does show; this probe replicates the real client in [app.js](../src/ui/app.js)'s `connectWs` — reads the binary frames, sends a `"ping"` text frame every 25 s, and **auto-reconnects on close with 500 ms→5 s backoff** — so a momentary device-side close registers as a short blip, not a frozen preview. Pure WebSocket client: **no device-side changes**, it observes the unmodified stream the device already broadcasts. Reports, per device: colour frames + sustained fps, reconnects (each a visible blip), `maxgap` (the longest stretch with no colour frame — the real "did it freeze?" number), and a `SMOOTH` / `CHOPPY` / `DEAD` verdict. Diagnostic, not a gate — it always exits `0`; read the verdict. Runs against **every device checked in the Live tab** (or an explicit `--host`); with no host it sweeps every online device on the active network. +Browser-faithful **3D-preview stream health probe** — measures the device's `/ws` preview the way a real browser tab experiences it, so the numbers match what a person watching the [PreviewDriver](../docs/moonmodules/light/drivers.md#preview) preview sees. A plain one-shot WebSocket reader gives up the moment the device closes the socket, so it reports stalls a browser never shows (the browser reconnects) and misses the brief blips a browser does show; this probe replicates the real client in [app.js](../src/ui/app.js)'s `connectWs` — reads the binary frames, sends a `"ping"` text frame every 25 s, and **auto-reconnects on close with 500 ms→5 s backoff** — so a momentary device-side close registers as a short blip, not a frozen preview. Pure WebSocket client: **no device-side changes**, it observes the unmodified stream the device already broadcasts. Reports, per device: colour frames + sustained fps, reconnects (each a visible blip), `maxgap` (the longest stretch with no colour frame — the real "did it freeze?" number), and a `SMOOTH` / `CHOPPY` / `DEAD` verdict. Diagnostic, not a gate — it always exits `0`; read the verdict. Runs against **every device checked in the Live tab** (or an explicit `--host`); with no host it sweeps every online device on the active network. ```bash uv run moondeck/diag/preview_health.py # every online device, 30s each diff --git a/moondeck/check/check_devices.py b/moondeck/check/check_devices.py index 8f50d470..8d1374fd 100644 --- a/moondeck/check/check_devices.py +++ b/moondeck/check/check_devices.py @@ -11,7 +11,7 @@ Invariants checked per entry: - required fields present (name, chip, firmwares, modules) - firmwares is a non-empty list of non-empty strings (entry[0] is the default) - - image (if set) is a local assets/boards/ path that resolves on disk + - image (if set) is a local assets/deviceModels/ path that resolves on disk - url (if set) is an absolute http(s) link - the System module's `deviceModel` control value equals the entry `name` - every module `type` is factory-registered (or a known boot-wired singleton) @@ -135,8 +135,8 @@ def main(): # --- image resolves on disk + is a local path --- img = e.get("image") if img is not None: - if not isinstance(img, str) or not img.startswith("assets/boards/"): - errors.append(f"{where}: image must be a local 'assets/boards/...' path, got {img!r}") + if not isinstance(img, str) or not img.startswith("assets/deviceModels/"): + errors.append(f"{where}: image must be a local 'assets/deviceModels/...' path, got {img!r}") elif not (DOCS / img).exists(): errors.append(f"{where}: image '{img}' does not exist on disk") diff --git a/moondeck/check/check_specs.py b/moondeck/check/check_specs.py index d2cc3603..0b1da891 100644 --- a/moondeck/check/check_specs.py +++ b/moondeck/check/check_specs.py @@ -147,9 +147,11 @@ def _module_block(source_path, spec): lines = spec.splitlines() # Find the line tying this module to its block. Two link shapes across catalog # pages: a direct `source [<stem>.h]` (effects/modifiers/layouts) or a detail-page - # reference `[<base>.md]` / `alt="<base> controls"` (drivers link to detail pages). + # reference `[<base>.md]` / `alt="<base> controls"` (drivers link to detail pages), + # or a `Detail: [technical](../moxygen/<base>.md)` line (the consistent card link). def _match(ln): return (f"[{stem}]" in ln or f"[{base}.md]" in ln + or f"moxygen/{base}.md" in ln or f'alt="{base} ' in ln or f'alt="A {base} ' in ln) link_i = next((i for i, ln in enumerate(lines) if _match(ln)), None) if link_i is None: @@ -261,6 +263,10 @@ def check_source_links(): # \b before `source` so it matches the `source [Foo.h](…)` link marker, # not the tail of a word like "resource [". src_links = re.findall(r'\bsource \[[^\]]+\]\(([^)]+)\)', text) + if not src_links: + # Consolidated catalog pages now link each block to its generated + # technical page: `Detail: [technical](../moxygen/<Stem>.md)`. + src_links = re.findall(r'\]\(([^)]*moxygen/[^)]+\.md)\)', text) if not src_links: # index page: the per-entry links to detail pages (…Driver.md), # same-dir (no ../) — e.g. `](RmtLedDriver.md)`. @@ -319,6 +325,55 @@ def check_source_links(): issues.append((spec_rel, issue)) return issues +# --- registerType docPath resolution (guards the in-UI help links) -------------- +# main.cpp registers each module type with a `docPath` (relative to docs/moonmodules/) +# that the web UI turns into a per-card help link (see ModuleFactory.h). A docs rename +# that moves/deletes a page silently breaks those links — the UI 404s, and nothing else +# catches it. This check parses every registerType<T>("Name", "docPath") and asserts the +# docPath's file AND #anchor resolve, the same ground-truth the docs build would. +_REGISTER_RE = re.compile( + r'registerType<[^>]+>\(\s*"[^"]+"\s*,\s*"(?P<doc>[^"]+)"\s*\)') +# A markdown heading's anchor (the standard python-markdown `toc` slugify: lowercase, +# strip non-word/space/hyphen, spaces→hyphens, collapse repeats) — matches how MkDocs +# generates `id=` from a `##`/`###` heading. Explicit `<a id="x">` anchors are exact. +_HEADING_RE = re.compile(r'^#{1,6}\s+(?P<text>.+?)\s*$', re.MULTILINE) +_ANCHOR_ID_RE = re.compile(r'<a\s+id="(?P<id>[^"]+)"') + + +def _slugify(text: str) -> str: + s = text.strip().lower() + s = re.sub(r'[^\w\s-]', '', s) # drop punctuation/emoji (·, 💫, (), …) + s = re.sub(r'[\s]+', '-', s) # spaces → hyphens + s = re.sub(r'-+', '-', s).strip('-') + return s + + +def _page_anchors(md_path: Path) -> set: + """Every anchor a page exposes: explicit `<a id>` (catalog cards) + heading slugs.""" + text = md_path.read_text(encoding="utf-8", errors="replace") + anchors = set(_ANCHOR_ID_RE.findall(text)) + anchors.update(_slugify(m.group("text")) for m in _HEADING_RE.finditer(text)) + return anchors + + +def check_registertype_docpaths(): + """Each registerType docPath in main.cpp must resolve to a real page + anchor.""" + issues = [] + main_cpp = SRC / "main.cpp" + if not main_cpp.exists(): + return issues + for m in _REGISTER_RE.finditer(main_cpp.read_text(encoding="utf-8")): + doc = m.group("doc") + rel, _, frag = doc.partition("#") + page = SPECS / rel + if not page.exists(): + issues.append(f'registerType docPath "{doc}" — file not found: docs/moonmodules/{rel}') + continue + if frag and frag not in _page_anchors(page): + issues.append(f'registerType docPath "{doc}" — no anchor #{frag} in {rel}') + return issues + + def main(): modules = find_moonmodules() missing = [] @@ -344,10 +399,12 @@ def main(): ok.append(rel) source_link_issues = check_source_links() + docpath_issues = check_registertype_docpaths() # Report print(f"Spec check: {len(modules)} modules, {len(ok)} ok, {len(missing)} missing, " - f"{len(outdated)} outdated, {len(source_link_issues)} source-link issues") + f"{len(outdated)} outdated, {len(source_link_issues)} source-link issues, " + f"{len(docpath_issues)} docPath issues") if missing: print("\nMissing specs:") @@ -366,7 +423,12 @@ def main(): for spec, issue in source_link_issues: print(f" {spec} — {issue}") - if missing or outdated or source_link_issues: + if docpath_issues: + print("\ndocPath issues (main.cpp registerType → broken UI help link):") + for issue in docpath_issues: + print(f" {issue}") + + if missing or outdated or source_link_issues or docpath_issues: print() sys.exit(1) else: diff --git a/moondeck/docs/gen_api.py b/moondeck/docs/gen_api.py index 04b1699c..b6ba3b13 100644 --- a/moondeck/docs/gen_api.py +++ b/moondeck/docs/gen_api.py @@ -110,26 +110,11 @@ def _doxyfile(headers: list[str], xml_out: str) -> str: _BLOB_BASE = "https://github.com/MoonModules/projectMM/blob/main" -def _migration_crosscheck_header(header_rel: str, domain: str, stem: str) -> str: - """A TEMPORARY banner prepended to each generated page during the docs-v2 - migration: a link to the source `.h` (GitHub blob — `src/` isn't published to the - site) and, if one still exists, the original hand-written `<stem>.md` as an - IN-SITE relative link (that page still builds during the migration, so the link - resolves to its rendered `.html`), so a reviewer can cross-check that the `.md`'s - content was absorbed into the `.h`'s `///` comments. Removed at Stage 5.""" - parts = [f"[source `{Path(header_rel).name}`]({_BLOB_BASE}/{header_rel})"] - # The old per-module .md now lives under docs/moonmodules/<domain>/archive/. Find - # it by name (excluding the generated moxygen/ dirs); SORT so rglob's unspecified - # order can't make the chosen match (and thus the emitted relative path) vary build - # to build. Link RELATIVE to this generated page so MkDocs resolves it in-site. - this_dir = DOCS_MOONMODULES / domain / "moxygen" - for md in sorted(DOCS_MOONMODULES.rglob(f"{stem}.md")): - if "moxygen" in md.parts: - continue - rel = os.path.relpath(md, this_dir).replace(os.sep, "/") - parts.append(f"[original `{md.name}`]({rel})") - break - return f"> _Migration cross-check (temporary):_ {' · '.join(parts)}\n\n" +def _source_header(header_rel: str, domain: str, stem: str) -> str: + """A one-line banner linking each generated page to its source `.h` on GitHub — + `src/` isn't published to the site, so this is the only way to reach the header the + page is generated from.""" + return f"> _Source:_ [`{Path(header_rel).name}`]({_BLOB_BASE}/{header_rel})\n\n" # A moxygen inter-class link: `](cls_mm-<Class>.md#<anchor>)`, plus the namespace file @@ -178,6 +163,77 @@ def _strip_bad_anchor_links(md: str) -> str: return _BAD_ANCHOR_RE.sub("]", md) +# A `@card <file>` directive in a class `///` comment — the module's UI-card screenshot. +# Doxygen with GENERATE_HTML=NO drops `\image`/`@htmlonly`/raw `<img>` from the XML, but +# preserves plain text, so `@card foo.png` survives Doxygen → moxygen as-is and we render +# it to an `<img>` here (post-process). The asset lives at docs/assets/<domain>[/<sub>]/<file>; +# from a moxygen page (moonmodules/<domain>/moxygen/) that resolves to ../../../assets/… . +# `@card <file>` can land mid-line: Doxygen flows consecutive `///` lines into one paragraph, +# so in a richly-commented class the directive trails the last text run (`… esp_event.h. @card x.png`) +# rather than sitting on its own line. Match it ANYWHERE, with optional surrounding whitespace, and +# render to an <img> on its own block (a leading newline lifts it out of the trailing paragraph). +_CARD_RE = re.compile(r'[ \t]*@card\s+(?P<file>\S+\.(?:png|jpe?g|gif))[ \t]*') +_ASSETS = ROOT / "docs" / "assets" + + +def _render_card_directives(md: str, domain: str, stem: str) -> str: + """Replace each `@card <file>` directive with an <img> pointing at the resolved asset. + A file that doesn't exist on disk drops the directive (no broken image) — the same + fail-soft as a missing generated page.""" + def repl(m: re.Match) -> str: + fname = m.group("file") + # Search under docs/assets/<domain>/ for the file (handles the light/{drivers,effects,…} subdir). + hits = list((_ASSETS / domain).rglob(fname)) + if not hits: + return "" # asset absent → emit nothing rather than a broken link + rel = os.path.relpath(hits[0], DOCS_MOONMODULES / domain / "moxygen") + return f'\n\n<img src="{rel}" alt="{stem} card" width="300">\n' + return _CARD_RE.sub(repl, md) + + +# A member signature line moxygen emits as its own paragraph: a backtick-delimited +# code span alone on a line — an attribute (`uint8_t protocol = 0`) or a method +# (`virtual inline void onBuildControls() override`). The template wraps each in +# backticks; nothing else on the site opens a line with a bare code span, so this +# anchors the match to member signatures only. +_SIG_LINE_RE = re.compile(r'^`(?P<sig>[^`\n]+)`[ \t]*$', re.MULTILINE) +# A METHOD name is the identifier immediately before the FIRST argument-list `(` +# (`… onBuildControls(…) override` → `onBuildControls`) — trailing `const`/`override` +# come after and must NOT win. An ATTRIBUTE name is the identifier before ` =` or at +# the end of the declarator (`uint8_t protocol = 0` → `protocol`; `char pins[24] = ""` +# → `pins`, skipping the `[N]` array bound). Two anchored patterns, method tried first. +_SIG_METHOD_NAME_RE = re.compile(r'(?P<name>[A-Za-z_]\w*)\s*\(') +_SIG_ATTR_NAME_RE = re.compile(r'(?P<name>[A-Za-z_]\w*)\s*(?:\[[^\]]*\])?\s*(?:=|$)') + + +def _highlight_signature_names(md: str) -> str: + """Wrap the declared member NAME in each generated signature so the theme can + highlight it while the type/args stay muted (CSS: `.mm-sig-name`). moxygen emits + a flat `<code>` string with no internal markup, so 'colour only the name' can't + be done in CSS alone — we split the code span here into + `<code>…<span class="mm-sig-name">name</span>…</code>` (raw HTML the markdown + passes through). The signature reads as one code chip; only the identifier pops.""" + def repl(m: re.Match) -> str: + sig = m.group("sig") + # Method (has an arg list) → the id before the FIRST `(`; else attribute → + # the id before `=` / end. Falls through to the whole span if neither matches. + h = _SIG_METHOD_NAME_RE.search(sig) if "(" in sig else _SIG_ATTR_NAME_RE.search(sig) + if not h: + return m.group(0) + start, end = h.start("name"), h.end("name") + wrapped = (_html_escape(sig[:start]) + + f'<span class="mm-sig-name">{_html_escape(sig[start:end])}</span>' + + _html_escape(sig[end:])) + return f'<code class="mm-sig">{wrapped}</code>' + return _SIG_LINE_RE.sub(repl, md) + + +def _html_escape(s: str) -> str: + # Signatures carry `<`, `>`, `&` (templates, refs) — escape so the raw-HTML + # <code> we emit renders them as text, not markup. + return s.replace("&", "&").replace("<", "<").replace(">", ">") + + def _class_to_header(xml_dir: Path) -> dict[str, str]: """Map each moxygen class-file key → its source header (repo-relative), read from the Doxygen XML `<location file=...>` of every class/struct compound. The key is @@ -276,7 +332,9 @@ def generate() -> dict[str, str]: stem = Path(header).stem body = _rewrite_cls_links("".join(blocks), domain, cls_to_page) body = _strip_bad_anchor_links(body) - md = _migration_crosscheck_header(header, domain, stem) + body + body = _render_card_directives(body, domain, stem) + body = _highlight_signature_names(body) + md = _source_header(header, domain, stem) + body uri = f"moonmodules/{domain}/moxygen/{stem}.md" dst = DOCS_MOONMODULES / domain / "moxygen" / f"{stem}.md" dst.parent.mkdir(parents=True, exist_ok=True) diff --git a/moondeck/docs/mkdocs_hooks.py b/moondeck/docs/mkdocs_hooks.py index 74240670..1601db0a 100644 --- a/moondeck/docs/mkdocs_hooks.py +++ b/moondeck/docs/mkdocs_hooks.py @@ -72,7 +72,7 @@ def _rewrite_out_of_docs_links(markdown: str, src_uri: str) -> str: """Rewrite links that climb out of docs/ into repo files → absolute GitHub blob URLs, resolved against the page's own location so any `../` depth is handled. - src_uri is the page's path under docs/ (e.g. 'moonmodules/light/effects/effects.md').""" + src_uri is the page's path under docs/ (e.g. 'moonmodules/light/effects.md').""" # The page's directory within docs/ — the anchor `../` hops resolve against. page_dir = Path("docs") / src_uri page_dir = page_dir.parent @@ -122,16 +122,16 @@ def _sub(m: re.Match) -> str: # The consolidated catalog pages whose ### effect/modifier/layout blocks are rendered # as a table (source stays authored as readable prose blocks; the table is build-time). _CATALOG_PAGES = { - "moonmodules/light/effects/effects.md", - "moonmodules/light/modifiers/modifiers.md", - "moonmodules/light/layouts/layouts.md", - "moonmodules/light/drivers/drivers.md", + "moonmodules/light/effects.md", + "moonmodules/light/modifiers.md", + "moonmodules/light/layouts.md", + "moonmodules/light/drivers.md", # Summary pages for the non-catalog module groups use the same ### -block → table # transform, one common authoring process for every summary page (supporting rows # just leave the preview/controls columns blank). - "moonmodules/core/supporting/supporting.md", - "moonmodules/core/ui/ui.md", - "moonmodules/light/supporting/supporting.md", + "moonmodules/core/supporting.md", + "moonmodules/core/services.md", + "moonmodules/light/supporting.md", } _H3_RE = re.compile(r'^###\s+(?P<title>.+?)\s*$') @@ -184,9 +184,9 @@ def _emit_row(b: dict, details_names: set) -> str: # awaiting a gif and a supporting module that has no visual by nature. col2 = "—" - # Col 3: parameters, one per line. Split each at the em-dash into the name part + # Col 3: controls, one per line. Split each at the em-dash into the name part # (before — keeps its `code` chips, styled accent) and the description (after — - # greyed via .mm-pdesc, matching the muted module description). Params without a + # greyed via .mm-pdesc, matching the muted module description). Controls without a # dash render whole in the name part. col3_parts = [] for p in b["params"]: @@ -200,12 +200,18 @@ def _emit_row(b: dict, details_names: set) -> str: col3 = "".join(col3_parts) if col3_parts else "—" # Col 4: everything a reader clicks OUT to — so the description column is pure - # prose. Tests + detail-page link(s) + source/attribution + a ⌄ details anchor. + # prose. Tests + technical page + source/attribution + a ⌄ details anchor. Each + # link is prefixed with a Material icon (rendered as SVG by pymdownx.emoji, same as + # the tag emoji in the Name column) so the link TYPE is scannable, not a wall of + # small text — the recognizable docs-site convention. Labels are Title Case. links = [] if b["tests"]: - links.append(f"[Tests]({b['tests']})") + links.append(f":material-test-tube: [Tests]({b['tests']})") if b["detail"]: - links.append(b["detail"]) # the `Detail: [Foo.md](..) · …` line + # Title-case a lone `[technical]` label so it reads "Technical"; leave a named + # multi-link group (e.g. the LED-output `[RMT] · [LCD] · [Parlio]`) as authored. + detail = re.sub(r'^\[technical\]', '[Technical]', b["detail"]) + links.append(f":material-file-document-outline: {detail}") if b["origin"]: links.append(b["origin"]) # carries `source [Foo.h](...)` + attribution # The module's display name without the trailing ` 💫 · dim` decoration, e.g. @@ -247,7 +253,7 @@ def flush_table(): if rows: out.append('<div class="mm-catalog-wrap" markdown="1">') out.append("") - out.append("| Name | Preview | Parameters | Links |") + out.append("| Name | Preview | Controls | Links |") out.append("|------|---------|------------|-------|") out.extend(rows) out.append("") diff --git a/moondeck/docs/moxygen-templates/cpp/class.md b/moondeck/docs/moxygen-templates/cpp/class.md index 608ea57e..c1504292 100644 --- a/moondeck/docs/moxygen-templates/cpp/class.md +++ b/moondeck/docs/moxygen-templates/cpp/class.md @@ -13,13 +13,11 @@ {{detaileddescription}} -{{#each inheritedMemberGroups}} -### Inherited from {{linkedName name refid}} - -{{#each members}}- `{{kind}}` {{linkedName name refid}}{{#if (memberSummary this)}} — {{cell (memberSummary this)}}{{/if}} -{{/each}} - -{{/each}} +{{! Inherited members are NOT re-listed: the `> **Inherits:** [Base]` link above already + points the reader to the base class's own page, where those members are documented once. + Re-dumping the full base interface on every subclass (MoonModule/EffectBase have a large + surface) is bloat — a subclass page should show what THAT class adds, not restate its + superclass. This is the *No duplication* rule applied to the generated API pages. }} {{! Public API only: skip the private/protected member sections (`section` is the raw Doxygen kind, a stabler discriminator than the English label). A technical reference documents the surface a caller uses, not internals. moxygen registers diff --git a/moondeck/docs/screenshot_modules.py b/moondeck/docs/screenshot_modules.py index f04e9810..10df4d13 100644 --- a/moondeck/docs/screenshot_modules.py +++ b/moondeck/docs/screenshot_modules.py @@ -134,9 +134,34 @@ def asset_dir_for(type_name: str) -> Path: "FirmwareUpdateModule", "NetworkModule", "HttpServerModule", - "ImprovProvisioningModule", # ESP32-only — skipped if not in state + "MqttModule", + "FileManagerModule", + "DevicesModule", + # Present only in an ESP32 tree (peripherals / provisioning) — skipped on desktop + # (not in state); capture these against a board when needed. + "ImprovProvisioningModule", + "AudioModule", + "I2cScanModule", + "IrModule", ] +# Core modules that are CHILDREN of another module (so they have no top-level nav entry +# of their own): {child type → parent type}. The capture navs to the parent's nav root, +# then screenshots the child's own card. Everything not listed here is a top-level card. +CORE_NAV_ROOT = { + "MqttModule": "NetworkModule", + "DevicesModule": "NetworkModule", + "ImprovProvisioningModule": "NetworkModule", + # The System peripherals (Audio / IR / I2C scan) are children of SystemModule when present, + # but they're added per-board and never exist in the desktop tree — so they're captured + # against an ESP32, where these entries route the shot to the System nav root. + "AudioModule": "SystemModule", + "I2cScanModule": "SystemModule", + "IrModule": "SystemModule", +} +# FileManagerModule, FirmwareUpdateModule, SystemModule, NetworkModule are top-level +# (scheduler.addModule in main.cpp) — NOT listed here, so they capture as standalone cards. + # --------------------------------------------------------------------------- # Extra shots: MoonDeck tabs + web installer # Each entry: (filename, url, wait_selector, doc_files, anchor_text) @@ -435,15 +460,22 @@ def find_container_nav_names(host: str) -> dict[str, str]: def find_core_module_names(host: str) -> dict[str, str]: - """Return a map of type → live module name for core (always-present) modules.""" + """Return a map of type → live module name for core (always-present) modules. + Recurses the tree: some core modules (Mqtt, Devices, Improv) are children of + NetworkModule, not top-level, so a flat scan would miss them.""" r = _get(f"http://{host}/api/state", timeout=5) if not r.ok: return {} result: dict[str, str] = {} - for m in r.json().get("modules", []): - t = m.get("type", "") - if t in CORE_MODULES: - result[t] = m.get("name", "") + + def walk(mods): + for m in mods: + t = m.get("type", "") + if t in CORE_MODULES and t not in result: + result[t] = m.get("name", "") + walk(m.get("children", [])) + + walk(r.json().get("modules", [])) return result @@ -813,7 +845,15 @@ def _sweep_orphans(modules: list) -> None: skipped.append((type_name, "already exists")) continue print(f" {type_name} …", end=" ", flush=True) - ok = screenshot_container(page, args.host, cname, out_path) + # A top-level module is its own nav root (screenshot_container navs + cards by the + # same name). A CHILD module (Mqtt/Devices/Improv under Network) has no nav entry of + # its own — nav to its parent root, then screenshot the child's own card by name. + parent_type = CORE_NAV_ROOT.get(type_name) + if parent_type: + nav_name = core_names.get(parent_type, "") # live name of the nav root (e.g. "Network") + ok = screenshot_module(page, args.host, cname, nav_name, out_path) + else: + ok = screenshot_container(page, args.host, cname, out_path) print(f"saved → {out_path.relative_to(ROOT)}" if ok else "failed") if ok: captured.append(type_name) diff --git a/moondeck/run/preview_installer.py b/moondeck/run/preview_installer.py index ec59a317..237abec0 100644 --- a/moondeck/run/preview_installer.py +++ b/moondeck/run/preview_installer.py @@ -51,8 +51,8 @@ PICKER_BOARDS_JS = ROOT / "src" / "ui" / "install-picker-boards.js" STAGE_DIR = ROOT / "build" / "install-preview" # The preview mirrors the GitHub Pages layout: the installer at /install/, -# board images under /install/assets/boards/, and the releases tree under -# /install/releases/ — so an "image": "assets/boards/<slug>.jpg" path and the +# board images under /install/assets/deviceModels/, and the releases tree under +# /install/releases/ — so an "image": "assets/deviceModels/<slug>.jpg" path and the # "./releases/<tag>/" firmware path resolve identically here and on Pages, no # per-context special casing. A root redirect (/ → /install/) keeps the old # entry point working. @@ -87,7 +87,7 @@ def _stage_runtime_files(src_dir: Path, dst_dir: Path): def _stage_referenced_board_images(dst_dir: Path): """Stage only the board images a deviceModels.json entry references (mirrors the - deploy's filtered copy), under <dst_dir>/assets/boards/.""" + deploy's filtered copy), under <dst_dir>/assets/deviceModels/.""" boards_json = INSTALL_DIR / "deviceModels.json" if not boards_json.exists(): return @@ -96,10 +96,10 @@ def _stage_referenced_board_images(dst_dir: Path): except (ValueError, OSError): return for b in boards: - rel = b.get("image") # e.g. "assets/boards/<slug>.jpg" + rel = b.get("image") # e.g. "assets/deviceModels/<slug>.jpg" if not rel: continue - src = ROOT / "docs" / rel # source lives in docs/assets/boards/ + src = ROOT / "docs" / rel # source lives in docs/assets/deviceModels/ if src.is_file(): out = dst_dir / rel out.parent.mkdir(parents=True, exist_ok=True) @@ -115,7 +115,7 @@ def stage_install_page(): query parameter the picker honours). Layout matches Pages exactly: the installer under /install/, board images - under /install/assets/boards/, the releases tree under /install/releases/. + under /install/assets/deviceModels/, the releases tree under /install/releases/. A root index.html redirects / → /install/ so the historical `localhost:8421/` entry point still lands on the installer. The shared install-picker.js (under src/ui/, shared with the on-device UI) is copied in. diff --git a/src/core/AudioModule.h b/src/core/AudioModule.h index b4d2ff37..d8e577c6 100644 --- a/src/core/AudioModule.h +++ b/src/core/AudioModule.h @@ -1,5 +1,19 @@ #pragma once +#include "core/MoonModule.h" +#include "core/AudioFrame.h" +#include "core/AudioLevel.h" +#include "core/AudioBands.h" +#include "core/math8.h" // beatsin8 / sin8 — the simulated-audio oscillators +#include "light/WLEDAudioSyncPacket.h" // WLED audio-sync wire format (send/receive) +#include "platform/platform.h" + +#include <cstdint> +#include <cstdio> // snprintf for the read-out strings +#include <cstring> + +namespace mm { + /// Acquires an audio source and publishes an `AudioFrame` — an overall sound /// **level**, a 16-band frequency **spectrum**, and the **dominant peak** — as the /// producer half of the audio-reactive pipeline @@ -57,29 +71,16 @@ /// first few reads and self-corrects); a bad init leaves the module idle (zeroed /// frame), never crashing. /// -/// **Prior art:** audio-reactive lighting is a long-standing idea in the -/// LED-controller world (WLED-MM and MoonLight are the closest lineage). This is -/// projectMM's own implementation, designed from the INMP441 datasheet -/// (https://invensense.tdk.com/wp-content/uploads/2015/02/INMP441.pdf) and standard -/// DSP rather than traced from any one project. See -/// docs/moonmodules/core/moxygen/AudioModule.md for the full DSP rationale, the source-seam -/// backlog (line-in / PDM / analog / I2C codecs), and the forward-looking adaptive -/// noise-gate analysis. - -#include "core/MoonModule.h" -#include "core/AudioFrame.h" -#include "core/AudioLevel.h" -#include "core/AudioBands.h" -#include "core/math8.h" // beatsin8 / sin8 — the simulated-audio oscillators -#include "light/WLEDAudioSyncPacket.h" // WLED audio-sync wire format (send/receive) -#include "platform/platform.h" - -#include <cstdint> -#include <cstdio> // snprintf for the read-out strings -#include <cstring> - -namespace mm { - +/// **Prior art:** audio-reactive lighting is a long-standing idea in the LED-controller world +/// (WLED-MM and MoonLight are the closest lineage). This is projectMM's own implementation, designed +/// from the INMP441 datasheet (https://invensense.tdk.com/wp-content/uploads/2015/02/INMP441.pdf) and +/// standard DSP rather than traced from any one project — studying, with credit, the thinking of +/// Frank (softhack007, WLED-MM audioreactive), Troy (troyhacks, the esp-dsp FFT + biquad pre-filters +/// path we share), and Damian Schneider (DedeHai, the fixed-point FFT for FPU-less chips). The +/// forward-looking analysis (source-seam extensions — line-in / PDM / analog / I²C codecs — and the +/// adaptive-noise-gate design that would retire the borrowed `floor` squelch) is a design study in +/// docs/backlog/audio-dsp-roadmap.md. +/// @card AudioModule.png class AudioModule : public MoonModule { public: /// Block size = FFT size: a power of two. 512 samples at 22050 Hz is ~23 ms of diff --git a/src/core/DevicesModule.h b/src/core/DevicesModule.h index cf13777e..300c7910 100644 --- a/src/core/DevicesModule.h +++ b/src/core/DevicesModule.h @@ -66,11 +66,25 @@ namespace mm { /// transport: must-arrive config rides REST; latency-critical lossy-OK traffic /// (time sync, live pixels) rides its own UDP stream. /// +/// **WLED interop.** Because the presence broadcast and the mDNS advertise are +/// WLED-shaped, a projectMM device appears in the WLED ecosystem with no projectMM +/// software on the other side: it shows in a real WLED's own instances list (heard on +/// UDP 65506), and in the native WLED iOS/Android app (discovered via the `_wled._tcp` +/// mDNS advertise, validated via a `/json/info` shim — see HttpServerModule's +/// WLED-compatibility shim). +/// +/// **Wire shape.** The `devices` List serializes each row's `value` as +/// `{"name","ip","type",["self"]}`, with a parallel `detail` object carrying `url` and +/// `ageSec` (seconds since last heard, `now − lastSeenMs`; omitted on the self row, +/// always current). A row restored from persistence but not yet re-heard live this +/// session carries `cached:true` instead of `ageSec`; the UI shows "last seen: cached" +/// until an announcement re-confirms it (clearing `cached` and emitting a real `ageSec`, +/// rendered as "last seen 2m ago"). +/// /// **Prior art:** the industry-standard mDNS-SD / DNS-SD (Bonjour, Avahi) /// announce-and-browse pattern, plus MoonLight's UDP presence broadcast carried -/// forward as the 44-byte WLED-compatible packet on UDP 65506. See -/// docs/moonmodules/core/moxygen/DevicesModule.md for the WLED-interop screenshots + the -/// wire shape. +/// forward as the 44-byte WLED-compatible packet on UDP 65506. +/// @card DevicesModule.png class DevicesModule : public MoonModule, public ListSource { public: /// Wire this device's own name (deviceName) before setup so the self row matches the diff --git a/src/core/FileManagerModule.h b/src/core/FileManagerModule.h index c3875934..d347f0f6 100644 --- a/src/core/FileManagerModule.h +++ b/src/core/FileManagerModule.h @@ -40,6 +40,7 @@ namespace mm { /// **Prior art:** the lazy-loaded folder tree is the standard file-explorer shape (a node loads its /// children when expanded); the `/api/dir` + `/api/file` split mirrors the listing-vs-contents split /// every file API draws (WebDAV PROPFIND vs GET). +/// @card FileManagerModule.png class FileManagerModule : public MoonModule { public: ModuleRole role() const override { return ModuleRole::Peripheral; } diff --git a/src/core/FilesystemModule.h b/src/core/FilesystemModule.h index 8c30b9a1..d9f100ac 100644 --- a/src/core/FilesystemModule.h +++ b/src/core/FilesystemModule.h @@ -74,6 +74,7 @@ struct ControlDescriptor; /// member-initialised values; after the first UI change the debounce creates the file. /// A missing key keeps the default, an unknown key is silently ignored (no schema /// migration). +/// @card FilesystemModule.png class FilesystemModule : public MoonModule { public: static constexpr const char* CONFIG_DIR = "/.config"; diff --git a/src/core/FirmwareUpdateModule.h b/src/core/FirmwareUpdateModule.h index 9636d4d1..0d578c0c 100644 --- a/src/core/FirmwareUpdateModule.h +++ b/src/core/FirmwareUpdateModule.h @@ -73,10 +73,29 @@ inline bool otaInFlight() { /// desktop (`platform::hasOta == false`) the controls still exist for UI uniformity but the /// route returns 501 and status stays "idle" forever. /// -/// **Prior art:** `esp_https_ota` is the standard ESP-IDF OTA-from-HTTP component used by -/// every ESP32 OTA flow since IDF v4.x; the install-picker UI is the new layer on top. See -/// docs/moonmodules/core/moxygen/FirmwareUpdateModule.md for the `POST /api/firmware/url` wire -/// contract, the compatibility rules, and the flash lifecycle + error taxonomy. +/// **Flash lifecycle.** A `POST /api/firmware/url` (HttpServerModule, body `{"url": …}`) writes +/// `starting` and spawns the platform OTA task, which walks: `downloading` → `esp_https_ota_begin` +/// opens the TLS connection and follows redirects (a GitHub release URL 302s to +/// `release-assets.githubusercontent.com`) → `flashing`, `esp_https_ota_perform` advancing +/// `update_pct` 0→100 → `esp_https_ota_finish` commits the image to the next OTA partition and flips +/// the boot pointer → `rebooting`, a ~600 ms delay so the HTTP response reaches the browser first, +/// then `esp_restart()`. The device boots the new image and the UI re-reads `version` / `firmware`. +/// +/// **Error taxonomy** (all via the status slot, `error: ` prefix, staying until the next POST): +/// `error: ota begin <IDF name>` (DNS / TLS / no OTA partition), `error: ota perform <IDF name>` +/// (network drop mid-download), `error: incomplete download` (size mismatch), `error: ota finish +/// <IDF name>` (commit / boot-flip), `error: task create failed` (`xTaskCreate` OOM — no retry). A +/// wrong-firmware binary fails at `esp_https_ota_begin` (chip-family mismatch) or boot (partition +/// mismatch) — recoverable by a USB re-flash, not a brick. +/// +/// **Compatibility** is the *caller's* responsibility (the install-picker's `isCompatible()`): strip +/// `-eth*` from both firmware keys, equal identities are compatible — so `esp32` and `esp32-eth` are +/// mutually OTA-compatible (same chip, different feature flags), the legacy `esp32-eth-wifi` key +/// strips to `esp32`, and `esp32s3-n16r8` is only itself. +/// +/// **Prior art:** `esp_https_ota` is the standard ESP-IDF OTA-from-HTTP component used by every ESP32 +/// OTA flow since IDF v4.x; the install-picker UI is the new layer on top. +/// @card FirmwareUpdateModule.png class FirmwareUpdateModule : public MoonModule { public: /// Diagnostics keep ticking regardless of the user toggle; matches diff --git a/src/core/I2cScanModule.h b/src/core/I2cScanModule.h index 7fda8468..c3a2643e 100644 --- a/src/core/I2cScanModule.h +++ b/src/core/I2cScanModule.h @@ -42,6 +42,7 @@ namespace mm { /// **Prior art:** the bus-scan-as-a-feature mirrors MoonLight's I2C scan diagnostic; the seam /// name and probe range follow the Linux `i2c-tools` `i2cdetect` convention /// (https://manpages.debian.org/i2c-tools/i2cdetect.8.en.html). +/// @card I2cScanModule.png class I2cScanModule : public MoonModule { public: /// A diagnostic, like FirmwareUpdateModule / DevicesModule — keeps its diff --git a/src/core/ImprovProvisioningModule.h b/src/core/ImprovProvisioningModule.h index 534efb0c..79de80a6 100644 --- a/src/core/ImprovProvisioningModule.h +++ b/src/core/ImprovProvisioningModule.h @@ -49,7 +49,8 @@ namespace mm { /// `http://ip/` or `ERROR_UNABLE_TO_CONNECT`). Vendor `SET_TX_POWER` (`0xFD`) persists + applies /// a pre-association TX-power cap *before* any association attempt — the escape hatch for boards /// whose LDO browns out at full TX power, since the cap must land before the first association or -/// the board fails auth at 20 dBm before it is ever online. Vendor `APPLY_OP` (`0xFC`) carries +/// the board fails auth at 20 dBm before it is ever online. Payload `[1][dBm]` (0–21; 0 lifts the +/// cap); an out-of-range value replies with `error 0x81`. Vendor `APPLY_OP` (`0xFC`) carries /// ONE REST op as JSON, routed to HttpServerModule's apply-core — the exact same code the HTTP /// handlers call — so a REST call over the network and an `APPLY_OP` over serial execute /// identically. The web installer pushes a device-model's whole catalog config this way during @@ -57,8 +58,23 @@ namespace mm { /// defaults apply over the serial port the installer already owns during the flash — which is /// what lets the HTTPS installer page configure an `http://` device a browser fetch can't reach /// (mixed-content). Ops are idempotent; a long value chunks across frames into a reassembly -/// buffer, applied on the device's main loop when `last=1`; single-buffered (a new op errors -/// while the previous is unconsumed). +/// buffer, applied on the device's main loop when `last=1`; single-buffered (a new op errors with +/// `0x82` while the previous is unconsumed). +/// +/// **Frame payload shape.** `[0xFC][seq][last][chunk]`. The installer paces ops open-loop (a +/// fixed delay sized to the worst-case consume window) rather than reading the ack back, so a lost +/// op is improbable rather than impossible; each op is idempotent, so a re-flash re-applies +/// cleanly. +/// +/// **Parent-key caveat.** The serial `add` op names the parent `parent`, while HTTP +/// `POST /api/modules` names it `parent_id`; both feed `applyAddModule()` but parse different +/// keys, so an HTTP payload is NOT a drop-in APPLY_OP — rename `parent_id` → `parent`. The serial +/// key stays terse because every byte counts against the frame. +/// +/// **clearChildren pre-pass.** The installer does a `clearChildren` pre-pass for every add-parent +/// (not only replaceChildren containers) so "apply device defaults" lands the same with or without +/// an erase: the device-side `add` is idempotent on module id, so a persisted same-name child +/// would otherwise survive and the re-add be skipped. /// /// **Eth-only builds.** The serial listener runs on every ESP32 target, including Ethernet-only /// builds: they compile in the vendor RPCs plus `GET_CURRENT_STATE` / `GET_DEVICE_INFO`, so the @@ -75,6 +91,7 @@ namespace mm { /// `deploy/flashfs.py --wifi` covered the same rack-provisioning use case by baking credentials /// into a LittleFS partition image and re-flashing; Improv replaces that with live serial /// provisioning (devices stay running, no flash mode required). +/// @card ImprovProvisioningModule.png class ImprovProvisioningModule : public MoonModule { public: void setSystemModule(SystemModule* s) { systemModule_ = s; } diff --git a/src/core/IrModule.h b/src/core/IrModule.h index 12791b4a..6b51655c 100644 --- a/src/core/IrModule.h +++ b/src/core/IrModule.h @@ -47,6 +47,7 @@ namespace mm { /// **Prior art:** consumer IR remotes use the NEC protocol (a 32-bit address+command frame, /// LSB-first, ~9 ms lead burst); the ESP-IDF RMT peripheral decodes it (the espressif /// `ir_nec_transceiver` example). The decode itself lives behind `platform::irRead`. +/// @card IrModule.png class IrModule : public MoonModule { public: ModuleRole role() const override { return ModuleRole::Peripheral; } diff --git a/src/core/MoonModule.h b/src/core/MoonModule.h index af8a5b18..d680c611 100644 --- a/src/core/MoonModule.h +++ b/src/core/MoonModule.h @@ -59,6 +59,12 @@ inline const char* roleName(ModuleRole role) { /// module). Parents own their children's lifecycle and propagate every hook down — only /// top-level modules register with the Scheduler. /// +/// **Runtime add/remove lifecycle contract.** When a child is added or removed *after* the +/// parent's own setup has run, the caller drives the child's lifecycle: adding at runtime → +/// call `setup()` → `onBuildControls()` → `onBuildState()` on the new child; removing at runtime +/// → call `teardown()` on the child before removing it. A child added *before* the parent's +/// setup needs none of this — the parent's `setup()` propagates down to it. +/// /// **Enabled.** Every module has an `enabled` flag (default true), toggled from the UI card /// header and via `POST /api/control`. The Scheduler always calls the three loop hooks regardless /// of `enabled`; each module decides what "disabled" means — a rendering module early-returns @@ -341,6 +347,8 @@ class MoonModule { /// Replace child at position i with fresh. Caller owns lifecycle of the removed /// (returned) child — teardown + delete. Returns nullptr if i is out of range. + /// Used by FilesystemModule at load time to swap a child whose type differs from + /// the persisted JSON; the caller tears down + Scheduler::deleteTree's the old child. MoonModule* replaceChildAt(uint8_t i, MoonModule* fresh) { if (i >= childCount_ || !fresh) return nullptr; MoonModule* old = children_[i]; @@ -352,7 +360,8 @@ class MoonModule { /// Move child to absolute position newIndex (0..childCount-1). Intermediate siblings /// shift toward the vacated slot. Returns false if child isn't found, newIndex is out - /// of range, or the move is a no-op (already at newIndex). + /// of range, or the move is a no-op (already at newIndex). Used by the UI reorder path; + /// the caller's follow-up Scheduler::buildState() rebuilds any order-dependent LUT. bool moveChildTo(MoonModule* child, uint8_t newIndex) { if (newIndex >= childCount_) return false; for (uint8_t i = 0; i < childCount_; i++) { diff --git a/src/core/MqttModule.h b/src/core/MqttModule.h index f77ff1ac..bc4e8058 100644 --- a/src/core/MqttModule.h +++ b/src/core/MqttModule.h @@ -39,9 +39,10 @@ namespace mm { /// reads "No Response"). A dropped socket reconnects with a backoff. /// /// **Prior art:** the OASIS MQTT 3.1.1 standard + homebridge-mqttthing's topic conventions (see -/// docs/moonmodules/core/ui/ui.md#mqtt for the Homebridge accessory config). The MoonLight sibling +/// docs/moonmodules/core/services.md#mqtt for the Homebridge accessory config). The MoonLight sibling /// bridges the same on/off+brightness surface through a full framework MQTT client + HA discovery; /// projectMM writes its own lean client over the platform socket primitive instead. +/// @card MqttModule.png class MqttModule : public MoonModule { public: void setSystemModule(SystemModule* s) { systemModule_ = s; } diff --git a/src/core/NetworkModule.h b/src/core/NetworkModule.h index a9cb3d26..8f7a04b0 100644 --- a/src/core/NetworkModule.h +++ b/src/core/NetworkModule.h @@ -42,7 +42,18 @@ namespace mm { /// config — the `ethType` + pin controls, set per board in the device-model catalog and /// seeded from the per-chip default in `platform_config.h`. A W5500 change applies live /// (the SPI driver tears down and re-inits, no reboot); an RMII change saves and applies -/// on the next boot (the EMAC/clock teardown is fiddlier). +/// on the next boot (the EMAC/clock teardown is fiddlier). The eth controls, bound only on +/// an Ethernet-capable build (`platform::hasEthernet`) and shown per PHY type: +/// - `ethType` — PHY dropdown; the stored index maps 0=None, 1=LAN8720 (RMII), +/// 2=IP101 (RMII), 3=W5500 (SPI), 4=YT8531 (RGMII, the S31's on-chip 1 Gb EMAC), +/// matching the `EthPhyType` enum order. 0 shows no pin rows; a type reveals only its set. +/// - `ethPhyAddr` — SMI/PHY MDIO address (0..31, typically 0 or 1). +/// - `ethRstGpio` — PHY reset GPIO (−1 = none / module self-resets). +/// - `ethMdcGpio` / `ethMdioGpio` — RMII SMI clock / data GPIOs (−1 = IDF default). RMII only. +/// - `ethClockGpio` — RMII 50 MHz reference-clock GPIO; `ethClockExtIn` = clock direction +/// (on = fed IN by the board, off = chip drives it OUT). RMII only. +/// - `ethSpiMiso` / `ethSpiMosi` / `ethSpiSck` / `ethSpiCs` / `ethSpiIrq` — W5500 SPI pins +/// (`ethSpiIrq` −1 = polling). W5500 only. /// /// **mDNS:** included here (not a separate module). Registers the deviceName on whichever /// interface is active and re-registers when the active interface changes or the name is @@ -55,10 +66,12 @@ namespace mm { /// /// **`MM_IP=` boot token:** `currentIp()` writes the device's current LAN IP as octets; /// main.cpp formats it and appends a machine-parseable `MM_IP=<ip>` token to its -/// once-per-second tick line whenever there is an IP. The web installer reads this from -/// the boot serial log right after flashing to auto-add the device to "Your devices" — -/// timing-independent because the token rides the already-periodic tick line. Deliberately -/// IP-only; once the installer has the IP it reads everything else from the live REST API. +/// once-per-second tick line — gated to the first 60 s of uptime (the installer reads at +/// ~3–15 s after boot; afterwards the IP comes from the REST API, so a permanent token would +/// just be noise on the perf line). The web installer reads this from the boot serial log +/// right after flashing to auto-add the device to "Your devices" — timing-independent because +/// the token rides the already-periodic tick line. Deliberately IP-only; once the installer +/// has the IP it reads everything else from the live REST API. /// /// **Memory:** the network stack cost varies by mode (Ethernet ~20 KB, STA ~40 KB, AP /// ~30 KB, STA+AP during the shutdown delay ~60 KB, fully reclaimed after teardown). This @@ -84,6 +97,7 @@ namespace mm { /// **Prior art:** MoonLight — mDNS hostname advertising, REST API for network config, /// credentials persisted to SPIFFS. ESP-IDF — `esp_wifi.h`, `mdns.h`, `esp_netif.h`, /// `esp_event.h`. +/// @card NetworkModule.png class NetworkModule : public MoonModule { public: void setScheduler(Scheduler* s) { scheduler_ = s; } @@ -747,10 +761,10 @@ class NetworkModule : public MoonModule { /// would just waste RAM. Callers that need text format with formatDottedQuad at /// their boundary. Read by main.cpp's per-second tick line, which appends it as a /// stable `MM_IP=<ip>` token for the web installer's post-flash serial read — - /// riding the already-periodic tick line means the IP re-emits every second - /// (timing-independent: DHCP can take several seconds — measured ~7s on the - /// P4-NANO — and the installer reopens the port at its own pace, so a one-shot - /// connect-time line is easy to miss). + /// riding the already-periodic tick line means the IP re-emits every second for the + /// first 60 s of uptime (timing-independent: DHCP can take several seconds — measured + /// ~7s on the P4-NANO — and the installer reopens the port at its own pace, so a + /// one-shot connect-time line is easy to miss; the 60 s cap lives in main.cpp). void currentIp(uint8_t out[4]) const { out[0] = out[1] = out[2] = out[3] = 0; if (state_ == State::ConnectedEth) platform::ethGetIPv4(out); diff --git a/src/core/SystemModule.h b/src/core/SystemModule.h index 40c1313b..2e2eafad 100644 --- a/src/core/SystemModule.h +++ b/src/core/SystemModule.h @@ -23,9 +23,16 @@ namespace mm { /// (largest contiguous allocatable block). /// - *Configurable:* `deviceName` (default `MM-XXXX`, XXXX = last 4 hex of the MAC) and /// `deviceModel` (display-only in the UI, pushed by tooling). -/// - *Static (set at boot):* `chip`, `sdk`, `flash`, `bootReason`, and `wifiCoproc` -/// (only on boards whose radio is a separate chip). On desktop the hardware-specific -/// fields read "desktop" / "N/A". +/// - *Static (set at boot):* `chip`, `sdk`, `flash`, `bootReason`, and `wifiCoproc`. +/// On desktop the hardware-specific fields read "desktop" / "N/A". +/// +/// **`wifiCoproc`:** shown only on boards whose radio is a separate chip (the ESP32-P4 +/// with its on-board ESP32-C6 over esp_hosted); absent on native-radio targets (the +/// platform returns an empty string and the control is not added). Reports the detected +/// slave firmware version (`C6 fw 2.12.9`) when the link is up, or `not detected` when +/// the C6 never completes its handshake / reports 0.0.0 — the signature of absent or +/// incompatible C6 slave firmware. `loop1s()` re-queries it, so the state stays current +/// if the link comes up after boot or the C6 is reflashed without a host reboot. /// /// **Device name:** `deviceName` is the single network identity across the system — /// NetworkModule uses it as the mDNS hostname (`<name>.local`), the SoftAP SSID, and the @@ -60,6 +67,7 @@ namespace mm { /// /// **Prior art:** MoonLight — system diagnostics via REST API; device name used for /// mDNS. +/// @card SystemModule.png class SystemModule : public MoonModule { public: void setScheduler(Scheduler* s) { scheduler_ = s; } diff --git a/src/light/E131Packet.h b/src/light/E131Packet.h index 1c730e12..c964279d 100644 --- a/src/light/E131Packet.h +++ b/src/light/E131Packet.h @@ -24,7 +24,7 @@ namespace mm { // 108 priority (default 100) 109-110 sync address (0 = none) // 111 sequence 112 options (0) // 113-114 universe (1-based per spec; we transmit whatever the caller says -// — see the universe rule in NetworkSendDriver.md) +// — see the universe rule on NetworkSendDriver's `universeStart` control) // DMP layer 115–125: // 115-116 flags+length 0x7000 | (totalLen − 115) // 117 vector 0x02 118 address & data type 0xA1 diff --git a/src/light/drivers/DriverBase.h b/src/light/drivers/DriverBase.h new file mode 100644 index 00000000..1e9ac4ef --- /dev/null +++ b/src/light/drivers/DriverBase.h @@ -0,0 +1,174 @@ +#pragma once + +#include "core/MoonModule.h" +#include "light/layers/Buffer.h" +#include "light/layers/Layer.h" +#include "light/drivers/Correction.h" +#include "platform/platform.h" + +#include <cstring> + +namespace mm { + +/// Base class for one driver — a consumer that reads the shared source buffer and +/// emits it to a destination (a physical LED output, a network sink, or the preview). +/// +/// A driver optionally reads dimensions from an active Layer, optionally applies the +/// shared output correction, and optionally restricts its output to a contiguous +/// *window* of the source buffer (see `addWindowControls`). It plays the same +/// zero-state role for drivers that EffectBase does for effects. +class DriverBase : public MoonModule { +public: + ModuleRole role() const override { return ModuleRole::Driver; } + virtual void setSourceBuffer(Buffer* buf) = 0; + /// Set the active Layer this driver reads dimensions from. Optional: drivers that + /// need dimensions (such as PreviewDriver describing the LED grid in the WebSocket + /// frame) call `layer_` for current physical width/height/depth; ArtNet doesn't need + /// it — it just streams bytes. + /// + /// This is the *active* layer for dimension queries, not a 1:1 wiring constraint: + /// each driver outputs to a single physical fixture, and the Drivers container hands + /// it one Layer for dimensions regardless of how many layers feed the output buffer. + void setLayer(Layer* layer) { layer_ = layer; } + + /// First light of the configured window. Public for tests pinning the slice + /// arithmetic; production reads via `windowSlice()`. See `start_`/`count_` below. + uint16_t windowStart() const { return start_; } + /// Number of lights in the configured window (0 = to end of buffer). + uint16_t windowCount() const { return count_; } + /// Set the window directly (the UI sets it via the start/count controls; this is + /// for code-wiring a driver's slice and for tests). Takes effect on the next config + /// parse / loop, like a control edit. + void setWindow(uint16_t start, uint16_t count) { start_ = start; count_ = count; } + /// The active Layer this driver reads dimensions from — null when no Layer is wired + /// (such as after the last Layer was deleted). Drivers must tolerate null here. + Layer* layer() const { return layer_; } + + /// Hand this driver the shared output correction (brightness LUT + channel order + + /// white) owned by the Drivers container. Default no-op so Preview (which shows the + /// raw logical buffer) and any preview-style driver opt out for free; only physical + /// drivers (ArtNet, LED) override to apply it. + virtual void setCorrection(const Correction* /*c*/) {} + + /// Notified by Drivers when the shared Correction's outChannels may have changed (a + /// lightPreset switch RGB↔RGBW). Default no-op; physical drivers that own an + /// intermediate correction-applied buffer override to resize it OFF the hot path. + /// Topology changes (light count, channels per light) already flow through + /// onBuildState — this hook is just for the preset-driven channel-count change that + /// doesn't trigger a structural rebuild. + virtual void onCorrectionChanged() {} + + /// Clear every shared status string on teardown — fail buffer, config error, and config + /// warning — so a stopped driver leaves nothing behind (frees the owned `failBuf_`; retracts + /// the warning the same "clear only MY status" way as the error). A driver that overrides + /// teardown() for its own peripheral cleanup chains to this afterwards: + /// `deinit(); DriverBase::teardown();`. + void teardown() override { clearFailBuf(); clearConfigErr(); setConfigWarn(nullptr); } + +protected: + Layer* layer_ = nullptr; + + // --- Shared source-buffer window (start, count) --------------------------- + /// Every driver reads the SAME shared source buffer (Drivers hands the one + /// `Buffer*` to each child) and outputs a contiguous slice of it: lights + /// `[start_, start_+count_)`. This is how light distribution is made *explicit* + /// and order-independent — a second driver on a different slice (such as an + /// onboard status LED at index 0, the main strip from index 1) just sets its + /// own window, rather than the buffer being split by driver order. `count_`==0 + /// means "the rest of the buffer from start_" (the common whole-buffer case). + /// NetworkSendDriver's universe maps onto the same window; the LED drivers' + /// pins/ledsPerPin distribute lights *within* the window. It is the alternative + /// to a "split the buffer by sibling order" model some controllers use — here the + /// user (or catalog) says which slice goes where. + uint16_t start_ = 0; ///< First source-buffer light this driver outputs (default 0). + uint16_t count_ = 0; ///< Lights to output from `start_`; 0 = to end of buffer. + + /// Add the two window controls — call from a driver's onBuildControls(). Kept a + /// helper (not auto-added) so a driver opts in by calling it where its other + /// controls go, keeping control *order* in the driver's hands. + void addWindowControls() { + controls_.addUint16("start", start_); + controls_.addUint16("count", count_); + } + + /// True if `name` is one of the window controls — a driver folds this into its + /// controlChangeTriggersBuildState() so editing the slice re-runs its config. + static bool isWindowControl(const char* name) { + return std::strcmp(name, "start") == 0 || std::strcmp(name, "count") == 0; + } + + /// Resolve the window against a buffer of `bufN` lights: writes the clamped first + /// light to `outStart` and the slice length to `outLen` (0 if the window starts + /// past the end — the driver then idles, no out-of-bounds read). The textbook + /// `[start, start+count)` clamp — every driver calls this instead of reading from + /// light 0. + void windowSlice(nrOfLightsType bufN, nrOfLightsType& outStart, + nrOfLightsType& outLen) const { + outStart = start_ < bufN ? start_ : bufN; + const nrOfLightsType avail = static_cast<nrOfLightsType>(bufN - outStart); + outLen = (count_ == 0 || count_ > avail) ? avail + : static_cast<nrOfLightsType>(count_); + } + + // --- Shared status-string lifecycle for the physical LED drivers (RMT / LCD / + // Parlio). They report two kinds of transient status that must clear cleanly + // without stomping an unrelated status set by something else: + // configErr_ — a borrowed string literal (a parse-error message); + // failBuf_ — an owned, on-demand char buffer (a formatted loopback/init + // failure with numbers in it). + // Both follow the same "clear only MY status" rule: only call clearStatus() if + // the status currently shown is the one this driver set. This was triplicated + // verbatim across the three drivers; it lives here once (the No-duplication + // rule). Preview-style drivers never touch these, so the cost is a couple of + // null pointers they ignore. + const char* configErr_ = nullptr; + const char* configWarn_ = nullptr; + char* failBuf_ = nullptr; + static constexpr size_t kFailBufLen = 48; + + // Record a parse/config error: set the status and remember it so clearConfigErr + // can later retract exactly this one. + void setConfigErr(const char* err) { + configErr_ = err; + setStatus(err, Severity::Error); + } + void clearConfigErr() { + if (configErr_) { + if (status() == configErr_) clearStatus(); + configErr_ = nullptr; + } + } + + // A non-fatal config warning (a borrowed literal, like configErr_): the driver keeps + // running but flags something the user should see (e.g. a per-pin count clamped to the + // WS2812 ceiling). `warn` non-null sets it; null retracts a stale one — so a driver calls + // setConfigWarn(warn) unconditionally each parse and the warning tracks the live state. + // Same "clear only MY status" rule as configErr_. Factored here so the WS2812 drivers + // (Rmt / LCD / Parlio) don't each inline it — the No-duplication rule this block records. + void setConfigWarn(const char* warn) { + if (warn) { + configWarn_ = warn; + setStatus(warn, Severity::Warning); + } else if (configWarn_) { + if (status() == configWarn_) clearStatus(); + configWarn_ = nullptr; + } + } + + // Lazily allocate the owned fail-message buffer (caller snprintf's into it then + // setStatus(failBuf_)). Returns null if the allocation fails, in which case the + // caller falls back to a literal status. + char* failBufEnsure() { + if (!failBuf_) failBuf_ = static_cast<char*>(platform::alloc(kFailBufLen)); + return failBuf_; + } + void clearFailBuf() { + if (failBuf_) { + if (status() == failBuf_) clearStatus(); + platform::free(failBuf_); + failBuf_ = nullptr; + } + } +}; + +} // namespace mm diff --git a/src/light/drivers/Drivers.h b/src/light/drivers/Drivers.h index 367ac9aa..1af626b8 100644 --- a/src/light/drivers/Drivers.h +++ b/src/light/drivers/Drivers.h @@ -1,5 +1,6 @@ #pragma once +#include "light/drivers/DriverBase.h" // DriverBase — the Drivers container casts its children to it #include "core/MoonModule.h" #include "light/layers/Buffer.h" #include "light/layers/Layer.h" @@ -13,167 +14,6 @@ namespace mm { -/// Base class for one driver — a consumer that reads the shared source buffer and -/// emits it to a destination (a physical LED output, a network sink, or the preview). -/// -/// A driver optionally reads dimensions from an active Layer, optionally applies the -/// shared output correction, and optionally restricts its output to a contiguous -/// *window* of the source buffer (see `addWindowControls`). It plays the same -/// zero-state role for drivers that EffectBase does for effects. -class DriverBase : public MoonModule { -public: - ModuleRole role() const override { return ModuleRole::Driver; } - virtual void setSourceBuffer(Buffer* buf) = 0; - /// Set the active Layer this driver reads dimensions from. Optional: drivers that - /// need dimensions (such as PreviewDriver describing the LED grid in the WebSocket - /// frame) call `layer_` for current physical width/height/depth; ArtNet doesn't need - /// it — it just streams bytes. - /// - /// This is the *active* layer for dimension queries, not a 1:1 wiring constraint: - /// each driver outputs to a single physical fixture, and the Drivers container hands - /// it one Layer for dimensions regardless of how many layers feed the output buffer. - void setLayer(Layer* layer) { layer_ = layer; } - - /// First light of the configured window. Public for tests pinning the slice - /// arithmetic; production reads via `windowSlice()`. See `start_`/`count_` below. - uint16_t windowStart() const { return start_; } - /// Number of lights in the configured window (0 = to end of buffer). - uint16_t windowCount() const { return count_; } - /// Set the window directly (the UI sets it via the start/count controls; this is - /// for code-wiring a driver's slice and for tests). Takes effect on the next config - /// parse / loop, like a control edit. - void setWindow(uint16_t start, uint16_t count) { start_ = start; count_ = count; } - /// The active Layer this driver reads dimensions from — null when no Layer is wired - /// (such as after the last Layer was deleted). Drivers must tolerate null here. - Layer* layer() const { return layer_; } - - /// Hand this driver the shared output correction (brightness LUT + channel order + - /// white) owned by the Drivers container. Default no-op so Preview (which shows the - /// raw logical buffer) and any preview-style driver opt out for free; only physical - /// drivers (ArtNet, LED) override to apply it. - virtual void setCorrection(const Correction* /*c*/) {} - - /// Notified by Drivers when the shared Correction's outChannels may have changed (a - /// lightPreset switch RGB↔RGBW). Default no-op; physical drivers that own an - /// intermediate correction-applied buffer override to resize it OFF the hot path. - /// Topology changes (light count, channels per light) already flow through - /// onBuildState — this hook is just for the preset-driven channel-count change that - /// doesn't trigger a structural rebuild. - virtual void onCorrectionChanged() {} - - /// Clear every shared status string on teardown — fail buffer, config error, and config - /// warning — so a stopped driver leaves nothing behind (frees the owned `failBuf_`; retracts - /// the warning the same "clear only MY status" way as the error). A driver that overrides - /// teardown() for its own peripheral cleanup chains to this afterwards: - /// `deinit(); DriverBase::teardown();`. - void teardown() override { clearFailBuf(); clearConfigErr(); setConfigWarn(nullptr); } - -protected: - Layer* layer_ = nullptr; - - // --- Shared source-buffer window (start, count) --------------------------- - /// Every driver reads the SAME shared source buffer (Drivers hands the one - /// `Buffer*` to each child) and outputs a contiguous slice of it: lights - /// `[start_, start_+count_)`. This is how light distribution is made *explicit* - /// and order-independent — a second driver on a different slice (such as an - /// onboard status LED at index 0, the main strip from index 1) just sets its - /// own window, rather than the buffer being split by driver order. `count_`==0 - /// means "the rest of the buffer from start_" (the common whole-buffer case). - /// NetworkSendDriver's universe maps onto the same window; the LED drivers' - /// pins/ledsPerPin distribute lights *within* the window. It is the alternative - /// to a "split the buffer by sibling order" model some controllers use — here the - /// user (or catalog) says which slice goes where. - uint16_t start_ = 0; ///< First source-buffer light this driver outputs (default 0). - uint16_t count_ = 0; ///< Lights to output from `start_`; 0 = to end of buffer. - - /// Add the two window controls — call from a driver's onBuildControls(). Kept a - /// helper (not auto-added) so a driver opts in by calling it where its other - /// controls go, keeping control *order* in the driver's hands. - void addWindowControls() { - controls_.addUint16("start", start_); - controls_.addUint16("count", count_); - } - - /// True if `name` is one of the window controls — a driver folds this into its - /// controlChangeTriggersBuildState() so editing the slice re-runs its config. - static bool isWindowControl(const char* name) { - return std::strcmp(name, "start") == 0 || std::strcmp(name, "count") == 0; - } - - /// Resolve the window against a buffer of `bufN` lights: writes the clamped first - /// light to `outStart` and the slice length to `outLen` (0 if the window starts - /// past the end — the driver then idles, no out-of-bounds read). The textbook - /// `[start, start+count)` clamp — every driver calls this instead of reading from - /// light 0. - void windowSlice(nrOfLightsType bufN, nrOfLightsType& outStart, - nrOfLightsType& outLen) const { - outStart = start_ < bufN ? start_ : bufN; - const nrOfLightsType avail = static_cast<nrOfLightsType>(bufN - outStart); - outLen = (count_ == 0 || count_ > avail) ? avail - : static_cast<nrOfLightsType>(count_); - } - - // --- Shared status-string lifecycle for the physical LED drivers (RMT / LCD / - // Parlio). They report two kinds of transient status that must clear cleanly - // without stomping an unrelated status set by something else: - // configErr_ — a borrowed string literal (a parse-error message); - // failBuf_ — an owned, on-demand char buffer (a formatted loopback/init - // failure with numbers in it). - // Both follow the same "clear only MY status" rule: only call clearStatus() if - // the status currently shown is the one this driver set. This was triplicated - // verbatim across the three drivers; it lives here once (the No-duplication - // rule). Preview-style drivers never touch these, so the cost is a couple of - // null pointers they ignore. - const char* configErr_ = nullptr; - const char* configWarn_ = nullptr; - char* failBuf_ = nullptr; - static constexpr size_t kFailBufLen = 48; - - // Record a parse/config error: set the status and remember it so clearConfigErr - // can later retract exactly this one. - void setConfigErr(const char* err) { - configErr_ = err; - setStatus(err, Severity::Error); - } - void clearConfigErr() { - if (configErr_) { - if (status() == configErr_) clearStatus(); - configErr_ = nullptr; - } - } - - // A non-fatal config warning (a borrowed literal, like configErr_): the driver keeps - // running but flags something the user should see (e.g. a per-pin count clamped to the - // WS2812 ceiling). `warn` non-null sets it; null retracts a stale one — so a driver calls - // setConfigWarn(warn) unconditionally each parse and the warning tracks the live state. - // Same "clear only MY status" rule as configErr_. Factored here so the WS2812 drivers - // (Rmt / LCD / Parlio) don't each inline it — the No-duplication rule this block records. - void setConfigWarn(const char* warn) { - if (warn) { - configWarn_ = warn; - setStatus(warn, Severity::Warning); - } else if (configWarn_) { - if (status() == configWarn_) clearStatus(); - configWarn_ = nullptr; - } - } - - // Lazily allocate the owned fail-message buffer (caller snprintf's into it then - // setStatus(failBuf_)). Returns null if the allocation fails, in which case the - // caller falls back to a literal status. - char* failBufEnsure() { - if (!failBuf_) failBuf_ = static_cast<char*>(platform::alloc(kFailBufLen)); - return failBuf_; - } - void clearFailBuf() { - if (failBuf_) { - if (status() == failBuf_) clearStatus(); - platform::free(failBuf_); - failBuf_ = nullptr; - } - } -}; - /// Top-level container for one or more drivers — the consumer side of the pipeline. /// Owns the shared output buffer (when memory allows) and performs blend+map from /// every layer's buffer into it each frame. @@ -225,6 +65,7 @@ class DriverBase : public MoonModule { /// `compositeLayers()` maps virtualChannels → channelsD, parallelism via a semaphore /// (driver signals completion, compositor writes) /// (https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/PhysicalLayer.h). +/// @card Drivers.png class Drivers : public MoonModule { public: const char* acceptsChildRoles() const override { return "driver"; } diff --git a/src/light/drivers/HueDriver.h b/src/light/drivers/HueDriver.h index 7b7f9823..7ffc2201 100644 --- a/src/light/drivers/HueDriver.h +++ b/src/light/drivers/HueDriver.h @@ -1,6 +1,6 @@ #pragma once -#include "light/drivers/Drivers.h" +#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 #include "core/DevicesModule.h" // DevicesModule::active() — list the bridge as a device @@ -12,40 +12,40 @@ namespace mm { -// Philips Hue lights as a projectMM OUTPUT — a driver, not a listed device. The bulbs are -// pixels of an effect: make a small grid (e.g. 4×1×1), run any effect, and this driver reads -// its window of the shared buffer and pushes each light's colour to the bridge. Same shape as -// NetworkSendDriver (read a window, send it out), but over the Hue v1 HTTP API instead of UDP. -// -// What makes Hue different from a strip and shapes the design: -// - It's HTTP, not a wire protocol: GET /api/<key>/lights, PUT .../lights/<id>/state. -// - Connection churn, not just Hue's ~10/s command budget, bounds the rate: each PUT opens a -// fresh TCP connection (the bridge speaks Connection: close), so loop() does AT MOST ONE PUT -// every kPutIntervalMs — a millis() gate, never work-every-tick — so a synchronous round-trip -// can't stall the single-thread render loop AND the TIME_WAIT sockets don't pile up. Smooth -// ambient colour, not real-time (that's the Entertainment API, a separate future). -// - Only colour-capable, reachable lights are driven (parseLights filters them); each PUT sends -// hue/sat/bri from a textbook RGB→HSV plus a cadence-matched transitiontime so the bulb glides. -// - It needs an app key: press the bridge's link button once, then POST /api to claim a key. -// Pairing is a short bounded poll across a few loop1s ticks (never blocking the loop). -// -// Room / light selection. Two dropdowns ("room", "light") let the user aim the effect at a -// subset of the bridge's colour bulbs without touching the window controls. fetchGroups reads -// GET /api/<key>/groups and keeps the bridge's Rooms (name + member light ids); fetchLights keeps -// each colour light's name. Option index 0 is "All" in BOTH dropdowns, so the common "drive -// everything" case never shifts index and persists as 0 for free (the Select stores its uint8 -// index). Picking a room narrows the light dropdown to that room's colour lights AND filters the -// driven set to that room; picking a specific light drives just that one. The filter builds -// drivenIdx_[] — the colour-light subset pushOneChangedLight actually walks — so room=All & -// light=All leaves the original behaviour (drive every colour bulb) untouched. -// -// Plain HTTP, no TLS — the Hue v1 API allows it (bench-confirmed on a BSB002 bridge). Prior -// art: the Hue v1 CLIP API (public docs); the effect-as-output mapping is projectMM's own. +/// Output driver: sends the buffer to Philips Hue bulbs as pixels — a driver, not a listed device. +/// The bulbs are pixels of an effect: make a small grid (e.g. 4×1×1), run any effect, and this +/// driver reads its window of the shared buffer and pushes each light's colour to the bridge. Same +/// shape as NetworkSendDriver (read a window, send it out), but over the Hue v1 HTTP API not UDP. +/// +/// It's HTTP, not a wire protocol (`GET /api/<key>/lights`, `PUT .../lights/<id>/state`), so the +/// rate is bounded by connection churn — each PUT opens a fresh TCP connection (the bridge speaks +/// `Connection: close`), and loop() does at most one PUT every `kPutIntervalMs` (see there) — giving +/// smooth ambient colour, not real-time. The shared output Correction applies as on the LED/network +/// drivers, so the brightness slider and colour-order preset reach the Hue lights too (brightness +/// 0 → light off). Only colour-capable, reachable lights are driven (see `parseLights`); the `room` +/// and `light` dropdowns aim the effect at a subset (see `rebuildDriven`). +/// +/// **Wire contract (Hue v1 API, plain HTTP, no TLS — bench-confirmed on a BSB002 bridge, API 1.77):** +/// - Pair — `POST http://<bridgeIp>/api` `{"devicetype":"projectMM#device"}`; before the link +/// button, the bridge returns `link button not pressed`; after, `[{"success":{"username":"<key>"}}]`. +/// - List lights — `GET http://<bridgeIp>/api/<appKey>/lights` → `{"1":{…},"2":{…}}` (window index → light id). +/// - List rooms — `GET http://<bridgeIp>/api/<appKey>/groups` → `{"1":{"name":…,"lights":["1","2"],"type":"Room"},…}`. +/// - Set a light — `PUT http://<bridgeIp>/api/<appKey>/lights/<id>/state` +/// `{"on":true,"bri":0-254,"hue":0-65535,"sat":0-254,"transitiontime":N}` (or `{"on":false}` for a black pixel). +/// +/// Prior art: the [Hue v1 CLIP API](https://developers.meethue.com/develop/hue-api/) (public docs); +/// the effect-as-output mapping is projectMM's own. +/// @card HueDriver.png class HueDriver : public DriverBase { public: - uint8_t bridgeIp[4] = {}; // the bridge's LAN IP (from the UI) - char appKey[48] = {}; // the Hue username/app key (filled by Pair, persisted) - + /// The bridge's LAN IP, entered in the UI (4 octets). + uint8_t bridgeIp[4] = {}; + /// The Hue username/app key — filled by the Pair button, then persisted. + char appKey[48] = {}; + + /// Register the controls: bridge IP, the persisted app key, the Pair link-button, the room + + /// light filter dropdowns (both default to index 0 = "All", rebuilt in place from the parsed + /// bridge data on every control change), the shared window, then refresh the status line. void onBuildControls() override { controls_.addIPv4("bridgeIp", bridgeIp); controls_.addText("appKey", appKey, sizeof(appKey)); // persisted credential @@ -63,17 +63,18 @@ class HueDriver : public DriverBase { refreshStatus(); } + /// Take the shared source buffer this driver reads its window from. void setSourceBuffer(Buffer* buf) override { sourceBuffer_ = buf; } - // The shared output Correction (global brightness LUT + channel order), same as the physical - // LED / network drivers — so the brightness slider and a swapped colour order reach the Hue - // lights too. Applied per pixel before RGB→HSV; the RGBW/white part is irrelevant here (Hue - // takes hue/sat), we use the RGB result. + /// Take the shared output Correction (global brightness LUT + channel order), same as the + /// physical LED / network drivers — so the brightness slider and a swapped colour order reach + /// the Hue lights too. Applied per pixel before RGB→HSV; the RGBW/white part is irrelevant here + /// (Hue takes hue/sat), we use the RGB result. void setCorrection(const Correction* c) override { correction_ = c; } - // A control click. "pair" starts the link-button pairing poll. Changing the bridge IP or app - // key points the driver at a (possibly) different bridge, so the learned light list + push - // cache are stale — drop them and let loop1s re-fetch against the new config. + /// A control click. "pair" starts the link-button pairing poll; changing the bridge IP or app + /// key points the driver at a (possibly) different bridge, so the learned light list + push + /// cache are dropped and re-fetched; a room/light change re-derives the driven subset. void onUpdate(const char* controlName) override { if (controlName && std::strcmp(controlName, "pair") == 0) { pairTicksLeft_ = kPairWindowTicks; // begin: poll the bridge for ~30 s on loop1s @@ -98,11 +99,11 @@ class HueDriver : public DriverBase { DriverBase::onUpdate(controlName); } - // loop() runs every render tick. It must NEVER do more than ONE bounded bridge call, and - // only when the rate-limit interval has elapsed (a millis() gate, NOT work-every-tick) — - // otherwise a synchronous HTTP round-trip stalls the whole single-thread render loop (the - // "never block the loop" rule, decisions.md). So: at most one PUT every kPutIntervalMs, - // round-robined across the lights. Pairing + the bridge announce ride the slow 1 Hz tick. + /// Runs every render tick, but does at most ONE bounded PUT and only when the rate-limit + /// interval has elapsed (a millis() gate, NOT work-every-tick) — otherwise a synchronous HTTP + /// round-trip would stall the single-thread render loop (the "never block the loop" rule, + /// decisions.md). One PUT every kPutIntervalMs, round-robined across the lights; pairing + the + /// bridge announce ride the slow 1 Hz tick. void loop() override { if (pairTicksLeft_ > 0) return; // pairing owns the bridge during its window if (!appKey[0] || !haveBridge() || lightCount_ == 0) return; @@ -112,9 +113,9 @@ class HueDriver : public DriverBase { pushOneChangedLight(); // exactly one bounded PUT this tick } - // The 1 Hz tick handles the non-render-critical, slower bridge work: pairing poll, the - // one-shot light fetch, and the periodic DevicesModule announce. Each is at most one bridge - // call per second — acceptable on a 1 Hz tick, and never in the per-frame loop(). + /// The 1 Hz tick handles the non-render-critical, slower bridge work: the pairing poll, the + /// one-shot light + group fetch, and the periodic DevicesModule announce. Each is at most one + /// bridge call per second — acceptable on a 1 Hz tick, and never in the per-frame loop(). void loop1s() override { if (pairTicksLeft_ > 0) { pollPairing(); DriverBase::loop1s(); return; } if (!appKey[0] || !haveBridge()) { DriverBase::loop1s(); return; } @@ -124,6 +125,8 @@ class HueDriver : public DriverBase { DriverBase::loop1s(); } + /// Stop any in-flight pairing and release the dropdown-name heap (a re-add re-fetches and + /// re-allocs), then chain to DriverBase::teardown to clear status. void teardown() override { pairTicksLeft_ = 0; freeNameBuffers(); // release the dropdown-name heap; a re-add re-fetches and re-allocs diff --git a/src/light/drivers/LcdLedDriver.h b/src/light/drivers/LcdLedDriver.h index 2850a8d9..a8c9a9ac 100644 --- a/src/light/drivers/LcdLedDriver.h +++ b/src/light/drivers/LcdLedDriver.h @@ -8,25 +8,25 @@ namespace mm { -// WS2812B output over the ESP32-S3 LCD_CAM i80 bus: up to 8 strands clock out -// SIMULTANEOUSLY, one GPIO lane each, fed consecutive slices of the source -// buffer. The S3's scale path — RMT gives it 4 channels, this gives 8 lanes for -// the wall time of one. -// -// The whole body — pins/ledsPerPin controls, the per-ROW fused correct+encode -// (LcdSlots.h), the fused reinit (the i80 bus owns the DMA buffer and its max -// transfer size is fixed at creation, so re-creating the bus IS the buffer -// resize), the latch pad, the loopback self-test — lives in ParallelLedDriver, -// shared with ParlioLedDriver. This class supplies only the i80-specific pieces: -// the sacrificial WR (pixel clock) + DC GPIOs the peripheral mandates even though -// WS2812 ignores both, the "exactly 8 pins" rule (the i80 layer rejects a partial -// bus), and the platform::lcdWs2812* calls. -// -// The whole frame (plus a >=300 µs zeroed latch pad) is pre-encoded off any ISR -// path, then one autonomous GDMA transfer ships it — no refill deadlines, so the -// WiFi-induced bit-slip of refill-based drivers can't occur. Prior art: Adafruit's -// LCD_CAM discovery, hpwit's I2SClockless lineage, FastLED's S3 driver — -// architecture studied, never copied (see LcdLedDriver.md). +/// Output driver: parallel WS2812B over the ESP32-S3 [LCD_CAM i80 bus](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-reference/peripherals/lcd/index.html). +/// The S3's scale path — RMT gives it 4 channels, this gives 8 lanes for the wall time of one. The +/// shared body (slicing, the single-shot DMA transfer, the fused encode, the loopback self-test) +/// lives in ParallelLedDriver; this class adds only the i80-specific pieces: +/// - The sacrificial WR (pixel clock) + DC GPIOs the peripheral mandates even though WS2812 ignores +/// both, and the "exactly 8 pins" rule (the i80 layer rejects a partial bus). +/// - **The 3-slot-per-bit wire contract:** each WS2812 bit becomes three bus slots at 2.67 MHz +/// (slot = 375 ns): all-active-lanes HIGH, the data bits, then all LOW — so a `1` is HIGH 750 ns +/// and a `0` 375 ns, approximating RMT's 700/350. The slot is deliberately NOT the lineage's +/// ~416 ns: newer WS2812B revisions spec T0H max ≈ 380 ns, and a longer `0` on a direct 3.3 V line +/// gets misread as `1` (the strip washes white). One 8-bit bus word per slot (bus bit L = the +/// L-th pin); unequal strands idle LOW once exhausted. Slot layout: LcdSlots.h. +/// - **Memory:** one internal-RAM DMA frame buffer (PSRAM deliberately unused — the peripheral +/// streams from internal SRAM): `longest lane × channels × 24 + latch` bytes, ~72 B/RGB light +/// (1000 lights across 8 lanes ≈ 9 KB; the boundary is ~1500+ lights on a SINGLE lane ≈ 110 KB). +/// - The platform::lcdWs2812* calls (ESP-IDF's esp_lcd i80 bus + GDMA). +/// +/// Prior art: Adafruit's LCD_CAM discovery, hpwit's I2SClockless lineage, FastLED's S3 driver — +/// architecture studied, never copied. class LcdLedDriver : public ParallelLedDriver<LcdLedDriver> { public: // Data pins + loopback pin default to UNSET: they are user-soldered (the strand @@ -38,44 +38,57 @@ class LcdLedDriver : public ParallelLedDriver<LcdLedDriver> { // strapping pins) — set those again to reproduce the bench. (Base declares // pins="" and loopbackRxPin=0, so the empty default needs no code here.) - // WR (pixel clock) and DC are different: the IDF i80 bus *requires* both on real - // GPIOs (esp_lcd_panel_io_i80.c: `wr_gpio_num >= 0 && dc_gpio_num >= 0`), yet the - // WS2812 strands ignore both — they are peripheral-fixed, not user-strand wiring, - // so a sensible overridable default cannot do harm (same class as the chip-fixed - // Ethernet pins). The data pins above gate startup, so the bus stays idle until - // the user sets them regardless. (Dropping WR/DC entirely needs a direct-LCD_CAM - // driver that bypasses esp_lcd, hpwit-style — backlogged, not this increment.) + /// WR (pixel clock) and DC: the IDF i80 bus *requires* both on real GPIOs + /// (esp_lcd_panel_io_i80.c: `wr_gpio_num >= 0 && dc_gpio_num >= 0`), yet the WS2812 strands + /// ignore both — they are peripheral-fixed, not user-strand wiring, so a sensible overridable + /// default cannot do harm (same class as the chip-fixed Ethernet pins). The data pins gate + /// startup, so the bus stays idle until the user sets them regardless. (Dropping WR/DC entirely + /// needs a direct-LCD_CAM driver that bypasses esp_lcd, hpwit-style — backlogged, not this + /// increment.) int8_t clockPin = 10; int8_t dcPin = 11; // --- CRTP hooks the base calls (all non-virtual; no vtable) --- + /// The number of i80 lanes this chip provides (0 = not this chip); the base's + /// inert-on-wrong-chip guards key off it. static constexpr uint8_t lanesAvailable() { return platform::lcdLanes; } static constexpr bool kExactLaneCount = true; // i80 needs all 8 data lanes static constexpr const char* kInitFailMsg = "LCD init failed — check pins / memory"; + /// Bind the i80-specific bus controls: the sacrificial WR (clockPin) and DC pins + /// the peripheral mandates. void addBusControls() { controls_.addPin("clockPin", clockPin); controls_.addPin("dcPin", dcPin); } + /// A clockPin or dcPin change triggers a bus rebuild via the onBuildState sweep. bool busControlTriggersBuild(const char* name) const { return std::strcmp(name, "clockPin") == 0 || std::strcmp(name, "dcPin") == 0; } + /// Create the i80 bus + its DMA buffer sized for `frameBytes` on the current data + /// lanes plus the WR/DC pins; returns whether init succeeded. bool busInit(size_t frameBytes) { return platform::lcdWs2812Init(lcd_, laneList_, laneCount_, static_cast<uint16_t>(clockPin), static_cast<uint16_t>(dcPin), frameBytes); } + /// The bus's DMA buffer the base encodes into. uint8_t* busBuffer() { return platform::lcdWs2812Buffer(lcd_); } + /// The DMA buffer's byte capacity (fixed at bus creation). size_t busCapacity() const { return platform::lcdWs2812BufferCapacity(lcd_); } + /// Kick off the autonomous transfer of the first `bytes` of the DMA buffer; + /// returns whether it started. bool busTransmit(size_t bytes) { return platform::lcdWs2812Transmit(lcd_, bytes); } + /// Block up to `ms` for the in-flight transfer to complete. void busWait(uint32_t ms) { platform::lcdWs2812Wait(lcd_, ms); } + /// Tear down the i80 bus and its DMA buffer. void busDeinit() { platform::lcdWs2812Deinit(lcd_); } - // The i80 layer requires all 8 data GPIOs valid, so a 1-lane private bus is - // impossible; the loopback builds the full-width bus and carries the pattern - // on lane 0. Passes the WR/DC pins the init needs. + /// Run the loopback self-test. The i80 layer requires all 8 data GPIOs valid, so + /// a 1-lane private bus is impossible; the loopback builds the full-width bus and + /// carries the pattern on lane 0. Passes the WR/DC pins the init needs. platform::RmtLoopbackResult busLoopback(const uint8_t* frame, size_t frameBytes, size_t dataBytes, uint8_t rowBits) { return platform::lcdWs2812Loopback(laneList_, laneCount_, @@ -85,9 +98,11 @@ class LcdLedDriver : public ParallelLedDriver<LcdLedDriver> { frame, frameBytes, dataBytes, rowBits); } - // Store WR/DC alongside the data pins, so a clockPin/dcPin edit rebuilds the - // bus too (not just a data-pin change). + /// Store WR/DC alongside the data pins, so a clockPin/dcPin edit rebuilds the + /// bus too (not just a data-pin change). void recordBusPins() { lastClockPin_ = clockPin; lastDcPin_ = dcPin; } + /// Whether the live bus's WR/DC pins still match the current clockPin/dcPin (so + /// the base can skip a rebuild). bool extraBusPinsCurrent() const { return lastClockPin_ == clockPin && lastDcPin_ == dcPin; } diff --git a/src/light/drivers/NetworkSendDriver.h b/src/light/drivers/NetworkSendDriver.h index 50fcc548..9a3a0089 100644 --- a/src/light/drivers/NetworkSendDriver.h +++ b/src/light/drivers/NetworkSendDriver.h @@ -3,7 +3,7 @@ #include "light/ArtNetPacket.h" // shared ArtNet wire formats (build + parse) #include "light/DdpPacket.h" // shared DDP wire format #include "light/E131Packet.h" // shared E1.31/sACN wire format -#include "light/drivers/Drivers.h" +#include "light/drivers/DriverBase.h" #include "platform/platform.h" #include <algorithm> // std::min in the chunk loop @@ -12,37 +12,52 @@ namespace mm { -// Lights-over-UDP output: one driver, three industry protocols selected by a -// control — ArtNet (510-channel universes), E1.31/sACN (the same universe -// split, ACN framing), and DDP (1440-byte byte-offset packets — the fast path: -// 480 RGB lights per packet vs 170, and per-packet cost is what dominates the -// wire time). The single-node-multiple-protocols shape follows MoonLight's -// D_NetworkOut (architecture studied, not copied). +/// Output driver: streams the buffer over UDP — one driver, three industry protocols selected by a +/// control. The single-node-multiple-protocols shape follows MoonLight's D_NetworkOut (architecture +/// studied, not copied). Byte layouts live in ArtNetPacket.h / E131Packet.h / DdpPacket.h, shared +/// with the receiver so the two sides cannot drift. +/// +/// **Interop:** unicast or limited-broadcast `255.255.255.255` (default, `SO_BROADCAST`) — NOT +/// multicast (no IGMP join; MoonLight ships without it too). E1.31 framing: CID stable per device +/// (from the MAC), source name `projectMM`, priority 100, one frame-level sequence per frame. +/// +/// **Synchronous send:** the whole frame goes out inline in loop() (~35 ms Ethernet / ~90 ms WiFi at +/// 128×128 ArtNet; DDP less). A decoupling send task is a PSRAM-gated backlog item. Added per board +/// via the catalog like the LED drivers; applies the same shared Correction, so network and wired +/// outputs show identical colours. +/// @card NetworkSendDriver.png class NetworkSendDriver : public DriverBase { public: - // Index-aligned with the protocol constants used in loop()'s switch: - // 0 = ArtNet, 1 = E1.31, 2 = DDP. The destination port follows the - // protocol (6454 / 5568 / 4048) — see connectIfDestChanged(). + /// Protocol names, index-aligned with the constants used in loop()'s switch (0 = ArtNet, + /// 1 = E1.31, 2 = DDP). The destination port follows the protocol (6454 / 5568 / 4048) — see + /// connectIfDestChanged(). static constexpr const char* kProtocolOptions[] = {"ArtNet", "E1.31", "DDP"}; static constexpr uint8_t kProtocolCount = 3; - // Destination address as 4 octets (not a dotted-quad string) — 4 bytes - // vs char[16], per docs/coding-standards.md § Prefer integers, store - // values in their native shape. The platform UdpSocket::connect() takes - // a string, so connectIfDestChanged() formats on a stack buffer at the - // boundary — the long-lived storage stays integer. - // Default to the limited-broadcast address so a fresh sender reaches every - // receiver on the LAN with no IP to type — set a unicast IP in the UI to target - // one device. Broadcast needs SO_BROADCAST, which platform UdpSocket::open sets. + /// Destination address as 4 octets — defaults to limited-broadcast so a fresh sender reaches + /// every receiver on the LAN with no IP to type; set a unicast IP in the UI to target one + /// device. Broadcast needs SO_BROADCAST, which platform UdpSocket::open sets. Stored as 4 bytes + /// (not a dotted-quad string), per docs/coding-standards.md § store values in their native + /// shape; UdpSocket::connect() takes a string, so connectIfDestChanged() formats on a stack + /// buffer at the boundary. uint8_t ip[4] = {255, 255, 255, 255}; - uint8_t protocol = 0; // index into kProtocolOptions - uint16_t universeStart = 0; // first universe (ArtNet/E1.31; DDP is byte-addressed) + /// Wire protocol (index into kProtocolOptions). Selects both the packet layout and the chunking: + /// ArtNet / E1.31 split at 510 channels per universe (whole RGB lights, the xLights/Falcon + /// convention; 170 lights/packet), consecutive universes from `universeStart`; DDP uses + /// 1440-byte byte-offset chunks (480 lights/packet) and is the fast path — per-packet cost + /// dominates wire time, so a 128×128 WiFi frame drops from ~110 ms (ArtNet) to ~40 ms. + uint8_t protocol = 0; + /// First universe the slice maps onto (ArtNet / E1.31; DDP is byte-addressed). Emitted verbatim, + /// no hidden 1-based adjust: buffer offset = `(universe − universeStart) × 510`. Strict sACN + /// reserves universe 0, so set ≥ 1 on BOTH ends for it; our own receiver defaults to 0 so + /// device↔device pairs align out of the box. Orthogonal to the DriverBase window (start/count), + /// which picks WHICH buffer slice is sent — this picks which universe it lands on. + uint16_t universeStart = 0; + /// Send-rate ceiling (Hz); loop() rate-limits to this so a fast render tick doesn't flood the LAN. uint8_t fps = 50; - // The buffer slice this sink sends is the shared DriverBase window: start_ + - // count_ ("count" 0 = the whole buffer from start). `universe_start` is the - // separate *protocol* offset (which DMX universe the slice maps onto), not a - // buffer offset — the two are orthogonal. + /// Register the controls in UI order: protocol, destination IP, universe offset, the shared + /// window (start/count), then the rate cap. void onBuildControls() override { controls_.addSelect("protocol", protocol, kProtocolOptions, kProtocolCount); controls_.addIPv4("ip", ip); @@ -51,58 +66,59 @@ class NetworkSendDriver : public DriverBase { controls_.addUint8("fps", fps, 1, 120); } - // A start/count change resizes the window this sink sends; route it through the - // onBuildState sweep so resizeCorrected() re-sizes corrected_ for the new slice — - // otherwise growing the window past the old corrected_ silently drops to passthrough. + /// A start/count change resizes the window this sink sends; route it through the onBuildState + /// sweep so resizeCorrected() re-sizes corrected_ for the new slice — otherwise growing the + /// window past the old corrected_ silently drops to passthrough. bool controlChangeTriggersBuildState(const char* name) const override { return isWindowControl(name); } + /// Open the socket and derive the stable E1.31 component id (CID) from the MAC once — no UUID + /// machinery needed for a deterministic, unique-enough id — then bind the destination so each + /// per-packet send skips the address parse + route lookup (re-bound in loop() on an ip/protocol + /// change; see connectIfDestChanged). void setup() override { socket_.open(); - // E1.31 wants a stable per-device component id; derive it from the MAC - // once — no UUID machinery needed for a deterministic, unique-enough CID. std::memcpy(cid_, "projectMM\0", 10); platform::getMacAddress(cid_ + 10); - // Bind the destination so each per-packet send skips the per-packet - // address parse + route lookup. Re-bound in loop() if the ip or - // protocol control changes (see connectIfDestChanged). connectIfDestChanged(); } + /// Close the socket on teardown; DriverBase::teardown (via the base) clears any status. void teardown() override { socket_.close(); } + /// Take the shared source buffer and (re)size the corrected_ buffer for it. Called from + /// Drivers::passBufferToDrivers inside onBuildState (and once at setup); resizeCorrected() is a + /// no-op while correction_ is still null on the first call, and the second call (after + /// setCorrection) lands the actual allocation. All off the hot path. void setSourceBuffer(Buffer* buf) override { sourceBuffer_ = buf; - // setSourceBuffer / setCorrection / setLayer are all called from - // Drivers::passBufferToDrivers, which runs inside Drivers::onBuildState - // (and once at setup). resizeCorrected() is a no-op while correction_ - // is still null on the first call; the second call (after setCorrection) - // lands the actual allocation. All off the hot path. resizeCorrected(); } + /// Take the shared output correction and re-size corrected_ to its channel count. void setCorrection(const Correction* c) override { correction_ = c; resizeCorrected(); } - // Topology change (light count, channels per light, or LUT path swap) — the - // framework calls onBuildState after Layer/Drivers reshape. Resize off the - // hot path so loop() never allocates. + /// Topology change (light count, channels per light, or LUT path swap) — the framework calls + /// this after Layer/Drivers reshape. Resize off the hot path so loop() never allocates. void onBuildState() override { resizeCorrected(); MoonModule::onBuildState(); } - // Preset toggle (RGB↔RGBW) changes correction_->outChannels without - // triggering a structural rebuild. Drivers::onUpdate forwards this hook. + /// Preset toggle (RGB↔RGBW) changes correction_->outChannels without a structural rebuild; + /// Drivers::onUpdate forwards this hook so corrected_ tracks the new channel count. void onCorrectionChanged() override { resizeCorrected(); } + /// Rate-limit to `fps`, apply the shared correction into corrected_ (passthrough if unwired), + /// then chunk the window slice into protocol packets and send the whole frame inline. void loop() override { if (!sourceBuffer_ || !sourceBuffer_->data()) return; diff --git a/src/light/drivers/ParallelLedDriver.h b/src/light/drivers/ParallelLedDriver.h index 2fb7daaf..2ca77c18 100644 --- a/src/light/drivers/ParallelLedDriver.h +++ b/src/light/drivers/ParallelLedDriver.h @@ -1,6 +1,6 @@ #pragma once -#include "light/drivers/Drivers.h" // DriverBase, Correction +#include "light/drivers/DriverBase.h" // DriverBase, Correction #include "light/drivers/LcdSlots.h" // encodeWs2812LcdSlots (shared encoder) #include "light/drivers/LedDriverConfig.h" #include "light/drivers/PinList.h" // parsePinList / assignCounts (shared) @@ -12,60 +12,69 @@ namespace mm { -// Shared body for the parallel WS2812 LED drivers: the S3's LCD_CAM i80 bus -// (LcdLedDriver) and the P4's Parlio peripheral (ParlioLedDriver). Both drive up -// to 8 strands that clock out simultaneously, one GPIO lane each, fed consecutive -// slices of the source buffer; both pre-encode the whole frame (a per-ROW fused -// correct+transpose, the SAME LcdSlots.h encoder — a Parlio bus byte and an i80 -// bus byte are identical) plus a zeroed >=300 µs latch pad into a platform-owned -// DMA buffer, then ship it as one autonomous transfer. The two were ~250 of ~370 -// lines byte-for-byte identical; this is the one copy (the No-duplication rule). -// -// Binding is CRTP (static polymorphism), NOT a virtual second hierarchy: the base -// calls back into the derived through `static_cast<Derived*>(this)->busX()` with -// no vtable and no runtime indirection, so it stays inside the hot-path / data- -// over-objects rules and the "one deliberate class hierarchy is the module tree" -// rule (the only virtual boundary remains MoonModule -> DriverBase). The derived -// supplies just the handful of peripheral-specific pieces: -// - bus* methods: thin wrappers over its platform::{lcd,parlio}Ws2812* calls -// (they own the handle, so the base never names the handle type); -// - static lanesAvailable() -> the platform::{lcd,parlio}Lanes constant the -// `if constexpr (... == 0)` inert-on-wrong-chip guards key off; -// - static kExactLaneCount -> true for i80 (its layer rejects a partial bus, -// so the pin list must name exactly 8); false for Parlio (1..8 lanes); -// - static kInitFailMsg, kClockHz (the slot rate), recordBusPins/extraBusPins- -// Current() for any extra pins the i80 driver tracks (WR/DC) that Parlio -// doesn't. -// configErr_/failBuf_ and their clear/ensure helpers come from DriverBase (shared -// with RmtLedDriver too). template <class Derived> +/// Base for the parallel WS2812B LED-output drivers — the S3's LCD_CAM i80 bus (LcdLedDriver) and +/// the P4's Parlio peripheral (ParlioLedDriver). Both drive up to 8 strands that clock out +/// SIMULTANEOUSLY, one GPIO lane each, fed consecutive slices of the source buffer. +/// +/// **Single-shot autonomous DMA:** both pre-encode the whole frame (a per-ROW fused +/// correct+transpose, the SAME LcdSlots.h encoder — a Parlio bus byte and an i80 bus byte are +/// identical: one word per slot, bit L = data line L) plus a zeroed ≥300 µs latch pad into a +/// platform-owned DMA buffer, then ship it as one autonomous transfer. So there's NO CPU deadline +/// during transmission — the WiFi-induced bit-slip of refill-based drivers cannot occur by +/// construction. (The i80 bus owns the DMA buffer and its max transfer size is fixed at creation, so +/// re-creating the bus IS the buffer resize.) The two drivers were ~250 of ~370 lines byte-for-byte +/// identical; this is the one copy (the No-duplication rule). +/// +/// **Buffer slicing across pins:** consecutive slices in `pins` order, sizes from `ledsPerPin`, +/// even-split remainder — identical semantics to RmtLedDriver, parsers shared (PinList.h). +/// +/// **CRTP, not a virtual hierarchy:** the base calls back into the derived through +/// `static_cast<Derived*>(this)->busX()` — no vtable, no runtime indirection — so it stays inside +/// the hot-path / data-over-objects rules and keeps the module tree as the one deliberate class +/// hierarchy (the only virtual boundary remains MoonModule → DriverBase). The derived supplies just +/// the peripheral-specific pieces: the bus* platform wrappers, `lanesAvailable()` (the inert-on- +/// wrong-chip `if constexpr` guard), `kExactLaneCount` (i80 needs exactly 8; Parlio runs 1..8), the +/// slot rate `kClockHz`, and any extra pins the i80 driver tracks (WR/DC) that Parlio doesn't. +/// configErr_/failBuf_ come from DriverBase (shared with RmtLedDriver too). class ParallelLedDriver : public DriverBase { public: - // Bus width this increment: 8 of the peripheral's 16 lanes (matches the - // platform's lane constant; widening to 16 is a later constant change). + /// Bus width this increment: 8 of the peripheral's 16 lanes (matches the platform's lane + /// constant; widening to 16 is a later constant change). static constexpr uint8_t kMaxLanes = 8; - // Comma-separated controls — shared 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. + /// 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 + /// 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 + /// kMaxLanes pins (a partial bus is rejected); Parlio runs on 1..8. char pins[24] = ""; + /// Comma-separated lights-per-lane, matched to `pins` by position; the unassigned remainder + /// splits evenly over the remaining lanes. Each lane is clamped to the WS2812 per-pin ceiling + /// (`kMaxWs2812LedsPerPin`) with a Warning status — it drives that many rather than choking a + /// whole grid onto one line. Empty default splits this driver's window evenly. char ledsPerPin[48] = ""; + /// On-device loopback self-test — jumper a lane's TX to `loopbackRxPin`, tick to transmit a + /// known WS2812 pattern and bit-verify the capture, proving the peripheral emits correct bytes + /// on real silicon. A persistent on/off mode (see onUpdate): while on it re-runs on every + /// relevant change; off clears the verdict. The verdict lands in the status slot. bool loopbackTest = false; - int8_t loopbackTxPin = -1; // optional TX override for the self-test: when - // set (>= 0), the loopback transmits on THIS - // pin in place of lane 0 (laneList_[0]); other - // lanes are unchanged. Lets the bench loopback run - // on a dedicated jumper pin without re-typing the - // operational `pins`. Falls back to laneList_[0] - // when unset (-1). The loopback only drives lane 0 - // with the test pattern, so a single override pin is - // all it needs. Test-only — normal output always uses - // the real `pins`. int8_t + addPin (not uint16): the - // standard single-GPIO control, and -1 = unset keeps - // GPIO 0 usable as a loopback pin. + /// Optional TX override for the self-test: when set (>= 0), the loopback transmits on THIS pin + /// in place of lane 0 (laneList_[0]); other lanes are unchanged. Lets the bench loopback run on + /// a dedicated jumper pin without re-typing the operational `pins`. Falls back to laneList_[0] + /// when unset (-1). The loopback only drives lane 0 with the test pattern, so a single override + /// pin is all it needs. Test-only — normal output always uses the real `pins`. int8_t + addPin + /// (not uint16): the standard single-GPIO control, and -1 = unset keeps GPIO 0 usable as a + /// loopback pin. + int8_t loopbackTxPin = -1; + /// Jumper this to the TX lane for the self-test (unset = -1 by default). int8_t loopbackRxPin = -1; + /// Bind the driver's controls: the window (start/count), the `pins` and + /// `ledsPerPin` text lists, any derived-supplied bus controls (i80 adds + /// clockPin/dcPin, Parlio none), and the loopback self-test controls (TX/RX pin + /// overrides always bound but shown only in test mode). void onBuildControls() override { addWindowControls(); // start / count — the slice of the shared buffer this driver outputs controls_.addText("pins", pins, sizeof(pins)); @@ -79,12 +88,19 @@ class ParallelLedDriver : public DriverBase { controls_.setHidden(controls_.count() - 1, !loopbackTest); } + /// A change to the pins, per-lane counts, the window, or a derived bus control + /// (clockPin/dcPin on i80) re-parses and re-inits the bus live via the + /// onBuildState sweep. bool controlChangeTriggersBuildState(const char* name) const override { return std::strcmp(name, "pins") == 0 || std::strcmp(name, "ledsPerPin") == 0 || isWindowControl(name) || derived()->busControlTriggersBuild(name); // clockPin/dcPin on i80 } + /// React to a control change off the render loop. loopbackTest is a persistent + /// on/off mode: while ON, the self-test re-runs on every relevant change (with a + /// lane-config refresh first, since onUpdate precedes the onBuildState sweep); + /// turning it OFF clears the verdict and re-derives the real driver status. void onUpdate(const char* name) override { const bool isTestControl = std::strcmp(name, "loopbackTest") == 0; const bool isPinControl = std::strcmp(name, "pins") == 0 @@ -108,24 +124,36 @@ class ParallelLedDriver : public DriverBase { } } + /// Parse the config and (re)init the bus. void setup() override { parseConfig(); reinit(); } + /// Deinit the bus, then clear the shared fail/config-error state + /// (DriverBase::teardown()). void teardown() override { deinit(); DriverBase::teardown(); // clears failBuf_ + configErr_ } + /// Topology or the pins/ledsPerPin controls changed — re-parse the lanes and + /// (re)init the bus off the hot path. void onBuildState() override { parseConfig(); reinit(); MoonModule::onBuildState(); } - // RGB<->RGBW changes the bytes-per-light and therefore the frame size. + /// RGB<->RGBW changes the bytes-per-light and therefore the frame size, so + /// re-parse and re-init the bus. void onCorrectionChanged() override { parseConfig(); reinit(); } + /// Point the driver at the source frame buffer and re-parse the lane config. void setSourceBuffer(Buffer* buf) override { sourceBuffer_ = buf; parseConfig(); } + /// Point the driver at the shared correction and re-parse the lane config. void setCorrection(const Correction* c) override { correction_ = c; parseConfig(); } + /// Per-tick output: a fused per-ROW pass corrects the same light index of every + /// active lane and transposes it into 3-slot bus bytes in the platform-owned DMA + /// buffer, then ships the frame as one autonomous transfer. Inert off this chip + /// and idle until inited with a source buffer + correction. void loop() override { if constexpr (Derived::lanesAvailable() == 0) return; // inert off this chip if (!inited_ || !dmaBuf_ || !sourceBuffer_ || !sourceBuffer_->data() @@ -162,12 +190,16 @@ class ParallelLedDriver : public DriverBase { derived()->busWait(1000 /* ms */); } - // Test-only accessors — pin the lane slicing and frame-size arithmetic on the - // host (unit_{Lcd,Parlio}LedDriver.cpp); the hardware half is proven on device. + /// Test-only accessors — pin the lane slicing and frame-size arithmetic on the + /// host (unit_{Lcd,Parlio}LedDriver.cpp); the hardware half is proven on device. uint8_t laneCount() const { return laneCount_; } + /// Lights on lane `i` (0 if out of range). Test-only. nrOfLightsType laneLightCount(uint8_t i) const { return i < laneCount_ ? laneCounts_[i] : 0; } + /// First light index of lane `i`'s slice (0 if out of range). Test-only. nrOfLightsType laneStart(uint8_t i) const { return i < laneCount_ ? laneStart_[i] : 0; } + /// Length of the longest lane — the frame's row count. Test-only. nrOfLightsType maxLaneLights() const { return maxLaneLights_; } + /// Total DMA frame size in bytes (rows + latch pad). Test-only. size_t frameBytes() const { return frameBytes_; } protected: diff --git a/src/light/drivers/ParlioLedDriver.h b/src/light/drivers/ParlioLedDriver.h index b8f066b8..3b77de40 100644 --- a/src/light/drivers/ParlioLedDriver.h +++ b/src/light/drivers/ParlioLedDriver.h @@ -7,23 +7,17 @@ namespace mm { -// WS2812B output over the ESP32-P4 Parlio (Parallel IO) TX peripheral: up to 8 -// strands clock out SIMULTANEOUSLY, one GPIO lane each, fed consecutive slices of -// the source buffer. The P4's scale path, sibling of LcdLedDriver. -// -// The whole body lives in ParallelLedDriver (shared with the i80 driver) — same -// pins/ledsPerPin controls, the SAME per-ROW encoder (a Parlio bus byte and an -// i80 bus byte are identical: one word per slot, bit L = data line L), the same -// fused lifecycle, latch pad, and single-shot autonomous-DMA transfer. This class -// supplies only the Parlio-specific pieces, and Parlio is the SIMPLER peripheral, -// so it supplies less than the i80 driver: -// - NO clockPin/dcPin: Parlio generates the pixel clock itself (kClockHz), -// so there are no sacrificial WR/DC lines (addBusControls is empty, the extra- -// pin tracking is a no-op). -// - kExactLaneCount = false: i80 rejects a partial bus, Parlio runs on 1..8 -// lanes — whatever `pins` names. -// Prior art: the ESP32-P4 Parlio peripheral, the hpwit/FastLED parallel-WS2812 -// lineage — architecture studied, never copied (see ParlioLedDriver.md). +/// Output driver: parallel WS2812B over the ESP32-P4 Parlio (Parallel IO) TX peripheral — the P4's +/// scale path, sibling of LcdLedDriver. The shared body (slicing, encode, single-shot DMA, loopback) +/// lives in ParallelLedDriver; Parlio is the SIMPLER peripheral, so this class adds LESS than the +/// i80 driver: +/// - NO clockPin/dcPin: Parlio generates the pixel clock itself (kClockHz), so there are no +/// sacrificial WR/DC lines (addBusControls is empty). +/// - kExactLaneCount = false: i80 rejects a partial bus; Parlio runs on 1..8 lanes — whatever +/// `pins` names. +/// +/// Prior art: the ESP32-P4 Parlio peripheral, the hpwit/FastLED parallel-WS2812 lineage — +/// architecture studied, never copied. class ParlioLedDriver : public ParallelLedDriver<ParlioLedDriver> { public: // All controls default to UNSET — pins="", ledsPerPin="" (= all lights on the @@ -37,6 +31,8 @@ class ParlioLedDriver : public ParallelLedDriver<ParlioLedDriver> { // --- CRTP hooks the base calls (all non-virtual; no vtable) --- + /// The number of Parlio lanes this chip provides (0 = not this chip); the base's + /// inert-on-wrong-chip guards key off it. static constexpr uint8_t lanesAvailable() { return platform::parlioLanes; } static constexpr bool kExactLaneCount = false; // 1..8 lanes all valid static constexpr const char* kInitFailMsg = "Parlio init failed — check pins / memory"; @@ -45,25 +41,37 @@ class ParlioLedDriver : public ParallelLedDriver<ParlioLedDriver> { // the P4 Parlio's 160 MHz PLL clock divides to it exactly (/60). static constexpr uint32_t kClockHz = 2'666'666; - // Parlio has no sacrificial clock/DC pins, so no extra controls and no extra - // pin tracking (the bus rebuilds on a data-pin change alone). + /// No bus controls: Parlio has no sacrificial clock/DC pins (the bus rebuilds on + /// a data-pin change alone). void addBusControls() {} + /// No extra bus controls, so none can trigger a rebuild. bool busControlTriggersBuild(const char*) const { return false; } + /// No extra pins to record (Parlio has no WR/DC). void recordBusPins() {} + /// No extra pins to track, so they are always current. bool extraBusPinsCurrent() const { return true; } + /// Create the Parlio bus + its DMA buffer sized for `frameBytes` on the current + /// lanes, driving the pixel clock at kClockHz; returns whether init succeeded. bool busInit(size_t frameBytes) { return platform::parlioWs2812Init(parlio_, laneList_, laneCount_, kClockHz, frameBytes); } + /// The bus's DMA buffer the base encodes into. uint8_t* busBuffer() { return platform::parlioWs2812Buffer(parlio_); } + /// The DMA buffer's byte capacity (fixed at bus creation). size_t busCapacity() const { return platform::parlioWs2812BufferCapacity(parlio_); } + /// Kick off the autonomous transfer of the first `bytes` of the DMA buffer; + /// returns whether it started. bool busTransmit(size_t bytes) { return platform::parlioWs2812Transmit(parlio_, bytes); } + /// Block up to `ms` for the in-flight transfer to complete. void busWait(uint32_t ms) { platform::parlioWs2812Wait(parlio_, ms); } + /// Tear down the Parlio bus and its DMA buffer. void busDeinit() { platform::parlioWs2812Deinit(parlio_); } - // Parlio runs on a single lane, so the loopback builds its own private 1-lane - // unit on lane 0 (no i80 full-bus workaround needed; no WR/DC to pass). + /// Run the loopback self-test. Parlio runs on a single lane, so the loopback + /// builds its own private 1-lane unit on lane 0 (no i80 full-bus workaround + /// needed; no WR/DC to pass). platform::RmtLoopbackResult busLoopback(const uint8_t* frame, size_t frameBytes, size_t dataBytes, uint8_t rowBits) { return platform::parlioWs2812Loopback(laneList_, laneCount_, diff --git a/src/light/drivers/PreviewDriver.h b/src/light/drivers/PreviewDriver.h index 0b44be61..1989bc5b 100644 --- a/src/light/drivers/PreviewDriver.h +++ b/src/light/drivers/PreviewDriver.h @@ -1,6 +1,6 @@ #pragma once -#include "light/drivers/Drivers.h" +#include "light/drivers/DriverBase.h" #include "light/light_types.h" // lengthType, nrOfLightsType #include "core/BinaryBroadcaster.h" #include "platform/platform.h" @@ -37,6 +37,7 @@ namespace mm { /// from this `count`, not from the light total. `stride` rises above 1 only when the /// point set would exceed the runtime send-buffer cap (`min(display, memory)`); below /// the cap every light is sent (stride 1), so a sparse layout streams in full. +/// @card PreviewDriver.png class PreviewDriver : public DriverBase { public: /// The 3D preview the web UI renders streams from this driver. Deleting or diff --git a/src/light/drivers/RmtLedDriver.h b/src/light/drivers/RmtLedDriver.h index 189f0c54..1876c764 100644 --- a/src/light/drivers/RmtLedDriver.h +++ b/src/light/drivers/RmtLedDriver.h @@ -1,6 +1,6 @@ #pragma once -#include "light/drivers/Drivers.h" // DriverBase, Correction +#include "light/drivers/DriverBase.h" // DriverBase, Correction #include "light/drivers/LedDriverConfig.h" #include "light/drivers/PinList.h" // parsePinList / assignCounts (shared with LcdLedDriver) #include "light/drivers/RmtSymbol.h" // encodeWs2812Symbols (host-testable) @@ -12,78 +12,84 @@ namespace mm { -// WS2812B output over the ESP32 RMT peripheral. One or more strands — one GPIO -// and one RMT TX channel per strand — fed consecutive slices of the source -// buffer, 8-bit, GRB. -// -// This is the readable EXAMPLE future LED drivers copy. It reads as a sibling of -// NetworkSendDriver: same DriverBase hooks, same per-light `correction_->apply()` -// guard pattern, same once-allocated owned buffer sized off the hot path. The -// only thing that differs is the emit — ArtNet packs corrected bytes into UDP -// universes; this fuses the correction and the WS2812 symbol-encode into one -// pass over the lights, then hands per-pin slices of the symbols to the -// platform. All channels are started before any is waited on, so a multi-pin -// frame costs the longest strand, not the sum. -// -// Domain code only: the symbol encode lives in RmtSymbol.h; the platform owns -// just the peripheral (platform::rmtWs2812*). On targets without RMT every -// platform call is an inert stub and the driver does nothing — guarded by -// `if constexpr (platform::rmtTxChannels == 0)` so it compiles everywhere. -// The pin/count parsing and buffer slicing run on every platform, which is what -// lets the host unit tests (unit_RmtLedDriver_pins.cpp) pin them. +/// Output driver: WS2812B-class addressable LEDs over the ESP32 [RMT (Remote Control +/// Transceiver)](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/rmt.html) +/// peripheral — one GPIO and one RMT TX channel per strand, fed consecutive slices of the source +/// buffer (8-bit, GRB). The default LED driver for classic-ESP32 and S3 board entries, and the +/// readable EXAMPLE future LED drivers copy: a sibling of NetworkSendDriver (same DriverBase hooks, +/// same per-light `correction_->apply()` guard, same once-allocated owned buffer sized off the hot +/// path); only the emit differs — this fuses the correction + WS2812 symbol-encode into one pass +/// (the encode is `RmtSymbol.h`, host-tested) then hands per-pin slices to the platform. +/// +/// **Wire contract — [WS2812B](https://cdn-shop.adafruit.com/datasheets/WS2812B.pdf):** 1-wire NRZ +/// at 800 kHz, no clock line. Each bit is a 1.25 µs cell that starts HIGH then drops LOW; the HIGH +/// duration encodes the bit (`0` = 350 ns high, `1` = 700 ns high), MSB-first per byte. Channel +/// order (GRB, GRBW, …) is applied by `Correction` before the encode, so the encoder is +/// order-agnostic. Frames latch on ≥300 µs idle-LOW. Timings live in `LedDriverConfig`, converted to +/// RMT ticks from the granted resolution (~40 MHz), never hard-coded to one clock. The platform owns +/// only the peripheral (`platform::rmtWs2812*`); on a chip without RMT TX channels those calls are +/// inert stubs, so it compiles everywhere. The peripheral half uses the modern RMT driver (ESP-IDF +/// 5.x "RMT v2": `rmt_new_tx_channel` / a copy encoder / `rmt_transmit`), not the legacy +/// channel-numbered API — not a preference: the legacy driver was removed entirely in ESP-IDF v6 +/// (the build IDF), so v2 is the only API that exists. On chips whose RMT has a DMA backend +/// (`SOC_RMT_SUPPORT_DMA` — the P4; the classic ESP32 has none) the whole-frame loopback capture +/// uses it. +/// +/// **Flicker on LEDs that should be off** is almost always a data-line signal-integrity problem +/// (3.3 V drive into a 5 V strip), not firmware — the "LED signal integrity" use-case guide has the +/// confirm-firmware-innocent playbook (`loopbackFrame`, the TX-power sweep) and the electrical fixes. +/// @card RmtLedDriver.png class RmtLedDriver : public DriverBase { public: - // Hard cap on the pin arrays: the largest RMT TX group of any supported - // chip (8 on classic ESP32; the S3 has 4 — enforced per target via - // maxPinsForTarget()). A fixed array bounded by a hardware constant, not a - // dynamic list: the bound can't grow at runtime. + /// Hard cap on the pin arrays: the largest RMT TX group of any supported chip (8 on + /// classic ESP32; the S3 has 4 — enforced per target via maxPinsForTarget()). A fixed + /// array bounded by a hardware constant, not a dynamic list: the bound can't grow at runtime. static constexpr uint8_t kMaxPins = 8; - // Comma-separated GPIO list, one RMT TX channel per pin ("18,17,16"). Text - // control so one field holds N pins — per-output (pin, count) rows are the - // WLED LED-settings pattern. The peripheral validates each pin at init; a - // parse error or failing pin lands in the status field and the driver idles. - // 24 bytes fit kMaxPins 2-digit GPIOs plus separators. The loopback - // self-test transmits on the FIRST pin, so it validates the actual output. - // Defaults to UNSET: the strand is user-soldered to whatever GPIO the user - // wired, so a hard-coded pin would be a guess that could drive a pin committed - // elsewhere — empty until set, idle meanwhile (the "default only when it cannot - // do harm" rule; see decisions.md). Bench wiring was pin "18". + /// Comma-separated GPIO list, one RMT TX channel per pin ("18,17,16"). Text control so one + /// field holds N pins — per-output (pin, count) rows are the WLED LED-settings pattern. The + /// peripheral validates each pin at init; a parse error or failing pin lands in the status + /// field and the driver idles. 24 bytes fit kMaxPins 2-digit GPIOs plus separators. Defaults + /// to UNSET: the strand is user-soldered to whatever GPIO the user wired, so a hard-coded pin + /// would be a guess that could drive a pin committed elsewhere — empty until set, idle + /// meanwhile (the "default only when it cannot do harm" rule; see decisions.md). Bench pin "18". char pins[24] = ""; - // Comma-separated lights-per-pin ("100,100,50"), matched to `pins` by - // position — each pin takes the next consecutive slice of the source - // buffer. May be empty or shorter than `pins`: the unassigned remainder - // splits evenly over the remaining pins (last pin takes the rounding - // remainder), so the empty default just splits the whole buffer evenly. + /// Comma-separated lights-per-pin ("100,100,50"), matched to `pins` by position — each pin + /// takes the next consecutive slice of the source buffer, in list order (pin 1 = `[0,n₁)`, + /// pin 2 = `[n₁,n₁+n₂)`, …). May be empty or shorter than `pins`: the unassigned remainder + /// splits evenly over the remaining pins (last takes the rounding remainder), so the empty + /// default splits the whole buffer evenly. Each pin is capped at a WS2812 per-pin ceiling + /// (2048 lights — a 1-wire line clocks ~30 µs/light, so 2048 is already ~16 FPS): a pin over + /// the ceiling is clamped (output stays lit) with a Warning status, guarding the common + /// misconfig of a whole grid on one pin. Use the start/count window to drive fewer lights, + /// not this safety cap. char ledsPerPin[48] = ""; - // Loopback self-test (replaces the old standalone test firmware): tick the - // checkbox to run a one-shot RMT TX→RX round-trip — jumper the FIRST pin in - // `pins` to `loopbackRxPin`, the test transmits a known WS2812 pattern and - // captures it back, proving the GPIO emits correct bytes on real silicon. - // The outcome goes to the MoonModule status slot (setStatus) with the right - // severity. loopbackTest is a persistent on/off mode (see onUpdate): while on, - // the test re-runs on every relevant change; turning it off clears the verdict. + /// On-device loopback self-test — RMT is a transceiver, so the driver verifies its own output + /// on real silicon (replaces the old standalone test firmware). Tick to run a one-shot RMT + /// TX→RX round-trip: jumper the first pin (TX) to `loopbackRxPin`, it transmits a known WS2812 + /// pattern, captures it back, decodes, compares → `loopback PASS` / `FAIL: …` / `jumper not + /// detected` in the status field. A persistent on/off mode (see onUpdate): while on the test + /// re-runs on every relevant change; turning it off clears the verdict. Hardware lives in + /// `platform::rmtWs2812Loopback*`. bool loopbackTest = false; // checkbox: on = run + keep re-running on change - int8_t loopbackTxPin = -1; // optional TX override for the test: when set - // (>= 0), the loopback transmits on THIS pin in - // place of pins[0], so the test can run on a - // dedicated jumper without re-typing the - // operational `pins`. Falls back to pins[0] when - // unset (-1). Test-only — normal output uses `pins`. - // int8_t + addPin (not uint16): single-GPIO controls - // use the standard Pin control, and -1 = unset lets - // GPIO 0 be a valid loopback pin (0-as-unset wouldn't). - int8_t loopbackRxPin = -1; // jumper this to the TX pin for the test - // (unset = -1 by default; bench used pin 5) - - // Whole-frame stress variant: instead of a 24-bit burst, transmit a real - // frame the size of the first pin's slice, back to back, and bit-verify the - // WHOLE capture. This is the one that catches frame-rate corruption and RF - // interference on the data line (the flicker class of bug) — a 24-bit burst - // passes through a wire that mangles a sustained frame. Shown only in test - // mode; the status names the first corrupted light on failure. + /// Optional TX override for the test: when set (>= 0), the loopback transmits on THIS pin in + /// place of pins[0], so the test can run on a dedicated jumper without re-typing the + /// operational `pins`. Falls back to pins[0] when unset (-1). Test-only — normal output uses + /// `pins`. int8_t + addPin (not uint16): single-GPIO controls use the standard Pin control, + /// and -1 = unset lets GPIO 0 be a valid loopback pin (0-as-unset wouldn't). + int8_t loopbackTxPin = -1; + /// Jumper this to the TX pin for the test (unset = -1 by default; bench used pin 5). + int8_t loopbackRxPin = -1; + + /// Whole-frame stress variant: instead of a 24-bit burst, transmit a real frame the size of + /// the first pin's slice, back to back, and bit-verify the WHOLE capture. This is the one that + /// catches frame-rate corruption and RF interference on the data line (the flicker class of + /// bug) — a 24-bit burst passes through a wire that mangles a sustained frame. Shown only in + /// test mode; the status names the first corrupted light on failure. On the classic ESP32, + /// which has no RMT DMA, the capture is capped to one channel's worth of symbols (~2 RGB lights) + /// and still clocked back to back; the S3/P4 capture the full frame via DMA. bool loopbackFrame = false; // 40 MHz RMT tick clock = 25 ns/tick: t0h 350ns→14, t1h 700ns→28, period @@ -95,6 +101,9 @@ class RmtLedDriver : public DriverBase { // PinList.h, shared with LcdLedDriver — both drivers slice the source // buffer from the same two text controls. + /// Bind the driver's controls: the window (start/count), the `pins` and + /// `ledsPerPin` text lists, and the loopback self-test controls (the TX/RX pin + /// overrides and frame-stress flag are always bound but shown only in test mode). void onBuildControls() override { addWindowControls(); // start / count — the slice of the shared buffer this driver outputs controls_.addText("pins", pins, sizeof(pins)); @@ -114,20 +123,20 @@ class RmtLedDriver : public DriverBase { controls_.setHidden(controls_.count() - 1, !loopbackTest); } - // Changing the pin list or the per-pin counts re-parses and re-inits the RMT - // channels (live, not reboot-to-apply), so the pipeline-wide onBuildState - // sweep runs and parseConfig()/reinit() pick up the new lists. + /// Changing the pin list or the per-pin counts re-parses and re-inits the RMT + /// channels (live, not reboot-to-apply), so the pipeline-wide onBuildState + /// sweep runs and parseConfig()/reinit() pick up the new lists. bool controlChangeTriggersBuildState(const char* name) const override { return std::strcmp(name, "pins") == 0 || std::strcmp(name, "ledsPerPin") == 0 || isWindowControl(name); } - // React to a control change (runs off the render loop, in the HTTP/API - // handler context — a blocking self-test here is fine). loopbackTest is a - // persistent on/off mode. While it's ON, the test (re-)runs on every relevant - // change — turning it on, OR editing pins / loopbackRxPin — so the pins can be - // set in any order and the result always reflects the current pins. Turning it - // OFF clears the result. + /// React to a control change (runs off the render loop, in the HTTP/API + /// handler context — a blocking self-test here is fine). loopbackTest is a + /// persistent on/off mode. While it's ON, the test (re-)runs on every relevant + /// change — turning it on, OR editing pins / loopbackRxPin — so the pins can be + /// set in any order and the result always reflects the current pins. Turning it + /// OFF clears the result. void onUpdate(const char* name) override { const bool isTestControl = std::strcmp(name, "loopbackTest") == 0; const bool isPinControl = std::strcmp(name, "pins") == 0 @@ -153,26 +162,29 @@ class RmtLedDriver : public DriverBase { } } - // Lifecycle has two deliberately-separate concerns, so the buffer half stays - // host-testable and a hardware-only guard can never strand it: - // - SYMBOL BUFFER (plain heap): resizeSymbols() / freeSymbols(), run on - // every platform. - // - RMT CHANNELS (hardware): reinit() / deinitAll(), RMT-targets-only - // (if constexpr). - // The original bug put the buffer free inside the hardware deinit(), which - // reinit() (a rebuild) calls — so a rebuild freed the buffer loop() needs. - // Keeping the two apart makes that mistake impossible here and lets the host - // unit test (unit_RmtLedDriver_lifecycle.cpp) pin it. + /// Parse the config and (re)init the RMT channels. Lifecycle has two + /// deliberately-separate concerns, so the buffer half stays host-testable and a + /// hardware-only guard can never strand it: + /// - SYMBOL BUFFER (plain heap): resizeSymbols() / freeSymbols(), run on + /// every platform. + /// - RMT CHANNELS (hardware): reinit() / deinitAll(), RMT-targets-only + /// (if constexpr). + /// The original bug put the buffer free inside the hardware deinit(), which + /// reinit() (a rebuild) calls — so a rebuild freed the buffer loop() needs. + /// Keeping the two apart makes that mistake impossible here and lets the host + /// unit test (unit_RmtLedDriver_lifecycle.cpp) pin it. void setup() override { parseConfig(); reinit(); } + /// Release the RMT channels and free the symbol buffer, then clear the shared + /// fail/config-error state (DriverBase::teardown()). void teardown() override { deinitAll(); freeSymbols(); DriverBase::teardown(); // clears failBuf_ + configErr_ } - // Topology (light count / channels) or the pins/ledsPerPin controls changed — - // re-parse the lists, resize the symbol buffer, and (re)init the channels off - // the hot path. loop() never allocates. + /// Topology (light count / channels) or the pins/ledsPerPin controls changed — + /// re-parse the lists, resize the symbol buffer, and (re)init the channels off + /// the hot path. loop() never allocates. void onBuildState() override { parseConfig(); resizeSymbols(); @@ -180,21 +192,29 @@ class RmtLedDriver : public DriverBase { MoonModule::onBuildState(); } - // Preset toggle (RGB↔RGBW) changes outChannels without a structural rebuild — - // the per-pin symbol offsets scale with outChannels, so re-derive them too. + /// Preset toggle (RGB↔RGBW) changes outChannels without a structural rebuild — + /// the per-pin symbol offsets scale with outChannels, so re-derive them too. void onCorrectionChanged() override { parseConfig(); resizeSymbols(); } + /// Point the driver at the source frame buffer; re-parse (counts derive from + /// its light count) and resize the symbol buffer to match. void setSourceBuffer(Buffer* buf) override { sourceBuffer_ = buf; parseConfig(); // counts derive from the buffer's light count resizeSymbols(); } + /// Point the driver at the shared correction; re-parse (per-pin symbol offsets + /// derive from outChannels) and resize the symbol buffer to match. void setCorrection(const Correction* c) override { correction_ = c; parseConfig(); // offsets derive from outChannels resizeSymbols(); } + /// Per-tick output: fuse the correction and WS2812 symbol-encode in one pass + /// over this driver's window, then start every pin's transmit before waiting on + /// any, so the tick costs the longest strand rather than the sum. Inert off RMT + /// chips and idle until inited with a source buffer + correction. void loop() override { if constexpr (platform::rmtTxChannels == 0) return; // inert off RMT chips if (!inited_ || !sourceBuffer_ || !sourceBuffer_->data() || !correction_) return; @@ -258,15 +278,19 @@ class RmtLedDriver : public DriverBase { if (cfg_.reset_us) platform::delayUs(cfg_.reset_us); } - // Test-only accessors. symbolBuffer/symbolCapacity mirror ArtNet's - // correctedBuffer() and let unit tests pin the buffer-lifecycle invariants a - // hardware bug already taught us; pinCount/pinLightCount/pinSymbolOffsetWords - // pin the multi-pin slice arithmetic (unit_RmtLedDriver_pins.cpp). Not part - // of any runtime API. + /// Test-only accessors. symbolBuffer/symbolCapacity mirror ArtNet's + /// correctedBuffer() and let unit tests pin the buffer-lifecycle invariants a + /// hardware bug already taught us; pinCount/pinLightCount/pinSymbolOffsetWords + /// pin the multi-pin slice arithmetic (unit_RmtLedDriver_pins.cpp). Not part + /// of any runtime API. const uint32_t* symbolBuffer() const { return symbols_; } + /// Words allocated in the symbol buffer. Test-only. size_t symbolCapacity() const { return symbolCap_; } + /// Number of parsed output pins (0 = idle). Test-only. uint8_t pinCount() const { return pinCount_; } + /// Lights on pin `i` (0 if out of range). Test-only. nrOfLightsType pinLightCount(uint8_t i) const { return i < pinCount_ ? pinCounts_[i] : 0; } + /// Word offset of pin `i`'s slice in the symbol buffer (0 if out of range). Test-only. size_t pinSymbolOffsetWords(uint8_t i) const { return i < pinCount_ ? pinOffsets_[i] : 0; } private: diff --git a/src/light/effects/AudioSpectrumEffect.h b/src/light/effects/AudioSpectrumEffect.h index 1268b222..9e0c13ba 100644 --- a/src/light/effects/AudioSpectrumEffect.h +++ b/src/light/effects/AudioSpectrumEffect.h @@ -22,6 +22,7 @@ namespace mm { // bands zero → dark, so it is safe on any target and any grid size (including // 0×0). On a 1D strip (height 1) the bars collapse to per-column brightness. // Author: projectMM original, on the WLED-SR GEQ / spectrum-analyser concept (Andrew Tuline) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h +/// Audio-reactive effect: colours the layer from the 16-band FFT spectrum. class AudioSpectrumEffect : public EffectBase { public: const char* tags() const override { return "📊"; } diff --git a/src/light/effects/AudioVolumeEffect.h b/src/light/effects/AudioVolumeEffect.h index 44e184ed..549620a7 100644 --- a/src/light/effects/AudioVolumeEffect.h +++ b/src/light/effects/AudioVolumeEffect.h @@ -12,6 +12,7 @@ namespace mm { // live frame from AudioModule::latestFrame(); with no mic (or silence) the frame is // zero and the grid stays dark, so the effect is safe on any target. // Author: projectMM original (VU-meter) +/// Audio-reactive effect: drives brightness/colour from the overall sound level. class AudioVolumeEffect : public EffectBase { public: const char* tags() const override { return "🔊"; } diff --git a/src/light/effects/BlurzEffect.h b/src/light/effects/BlurzEffect.h index c5777319..c7efe125 100644 --- a/src/light/effects/BlurzEffect.h +++ b/src/light/effects/BlurzEffect.h @@ -31,6 +31,7 @@ namespace mm { // // 1D in spirit (a strip of `nrOfLights` pixels) but declared D2 so it spans a 2D panel as a flat run // along the buffer's pixel index; the dot and blur work over the whole pixel count either way. +/// Audio-reactive effect: blurred dots positioned by frequency band. class BlurzEffect : public EffectBase { public: const char* tags() const override { return "🐙📊"; } // WLED-lineage · audio diff --git a/src/light/effects/BouncingBallsEffect.h b/src/light/effects/BouncingBallsEffect.h index 39c5748d..b995ea4f 100644 --- a/src/light/effects/BouncingBallsEffect.h +++ b/src/light/effects/BouncingBallsEffect.h @@ -26,6 +26,7 @@ namespace mm { // shared draw primitives. Per-column ball state lives on the heap (sized to width()×maxNumBalls), // allocated in onBuildState and freed in teardown — never a large inline member. // Author: Andrew Tuline (WLED-SR) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h +/// Physics effect: gravity-bounced balls trailing along the layer. class BouncingBallsEffect : public EffectBase { public: const char* tags() const override { return "💫🐙"; } // MoonLight origin · 2D diff --git a/src/light/effects/DemoReelEffect.h b/src/light/effects/DemoReelEffect.h index 2ef7240f..90e60dec 100644 --- a/src/light/effects/DemoReelEffect.h +++ b/src/light/effects/DemoReelEffect.h @@ -28,6 +28,7 @@ namespace mm { // Prior art: FastLED's DemoReel100 sketch (Mark Kriegsman) — the canonical "rotate through a list of // patterns on a timer" demo; the registry-driven, self-skipping variant is ours. // Author: projectMM original, on Mark Kriegsman's FastLED DemoReel100 pattern — https://github.com/FastLED/FastLED/blob/master/examples/DemoReel100/DemoReel100.ino +/// Showcase effect: cycles through other effects with a name overlay. class DemoReelEffect : public EffectBase { public: const char* tags() const override { return "🎬"; } // demo reel diff --git a/src/light/effects/DistortionWavesEffect.h b/src/light/effects/DistortionWavesEffect.h index 7b3bc636..467079d4 100644 --- a/src/light/effects/DistortionWavesEffect.h +++ b/src/light/effects/DistortionWavesEffect.h @@ -19,6 +19,7 @@ namespace mm { // Prior art: MoonLight E_WLED.h (the WLED port); projectMM v1/v2 DistortionWaves (those // used float sinf — this is the integer-sin8 equivalent). // Author: ldirko & blazoncek (WLED port) — https://editor.soulmatelights.com/gallery/1089-distorsion-waves , https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h +/// Interference effect: overlaid moving waves distorting the field. class DistortionWavesEffect : public EffectBase { public: const char* tags() const override { return "💫"; } // MoonLight / WLED origin diff --git a/src/light/effects/FireEffect.h b/src/light/effects/FireEffect.h index fb04ee2f..c23e2b6c 100644 --- a/src/light/effects/FireEffect.h +++ b/src/light/effects/FireEffect.h @@ -10,12 +10,13 @@ namespace mm { +// Author: Mark Kriegsman's Fire2012 (FastLED); MoonLight adapts MatrixFireFast by toggledbits — https://github.com/toggledbits/MatrixFireFast /// Fire2012-style heat field: sparks at the base rise and cool through the active /// palette (heat = palette index, cold at the low end, hottest at the high end); /// spark count scales with width. The flame colour comes from the active palette — /// the Lava palette (black->red->orange->yellow->white) gives the classic look; any /// palette works (Ocean/Forest turn the flame blue/green). -// Author: Mark Kriegsman's Fire2012 (FastLED); MoonLight adapts MatrixFireFast by toggledbits — https://github.com/toggledbits/MatrixFireFast +/// @card FireEffect.png class FireEffect : public EffectBase { public: const char* tags() const override { return "⚡️🦅"; } // FastLED origin (Fire2012-style) · David Jupijn / Rising Step diff --git a/src/light/effects/FixedRectangleEffect.h b/src/light/effects/FixedRectangleEffect.h index 506525e6..016417fa 100644 --- a/src/light/effects/FixedRectangleEffect.h +++ b/src/light/effects/FixedRectangleEffect.h @@ -23,6 +23,7 @@ namespace mm { // per-row when height>width), and the white-vs-colour chequerboard are reproduced exactly, // written fresh on EffectBase + the shared draw primitives. // Author: limpkin (MoonLight) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +/// Test effect: draws a fixed rectangle at set coordinates. class FixedRectangleEffect : public EffectBase { public: const char* tags() const override { return "💫"; } // MoonLight origin diff --git a/src/light/effects/FreqMatrixEffect.h b/src/light/effects/FreqMatrixEffect.h index f99328e6..2e68aab7 100644 --- a/src/light/effects/FreqMatrixEffect.h +++ b/src/light/effects/FreqMatrixEffect.h @@ -41,6 +41,7 @@ namespace mm { // /2560 divisor so a full-scale level·fx·sensitivity lands near 255 — the same response curve on our // integer level. These two scale conversions are the only deviations from the verbatim WLED math; // every constant (80 Hz, 0.25, 42·highBin, 3·lowBin) is otherwise preserved. +/// Audio-reactive effect: scrolls the dominant frequency as a colour column. class FreqMatrixEffect : public EffectBase { public: const char* tags() const override { return "🐙📊"; } // 1D · audio diff --git a/src/light/effects/FreqSawsEffect.h b/src/light/effects/FreqSawsEffect.h index 26fbb29b..4f375acc 100644 --- a/src/light/effects/FreqSawsEffect.h +++ b/src/light/effects/FreqSawsEffect.h @@ -38,6 +38,7 @@ namespace mm { // EffectBase + the shared draw / palette / beat8 primitives. Reads AudioModule::latestFrame(); // silence → every band decays → flat → dark, safe on any target and grid size. // Author: @TroyHacks (MoonLight / WLED MoonModules) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +/// Audio-reactive effect: sawtooth bands driven by the frequency spectrum. class FreqSawsEffect : public EffectBase { public: const char* tags() const override { return "💫📊"; } // MoonLight origin · audio diff --git a/src/light/effects/GEQ3DEffect.h b/src/light/effects/GEQ3DEffect.h index edc00139..eb59ecf7 100644 --- a/src/light/effects/GEQ3DEffect.h +++ b/src/light/effects/GEQ3DEffect.h @@ -27,6 +27,7 @@ namespace mm { // any target and grid size. (MoonLight's `softHack` anti-alias toggle is dropped — draw::line is a // crisp Bresenham; the `soft` arg has no projectMM equivalent.) // Author: @TroyHacks (MoonModules, GPLv3) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h +/// Audio-reactive 3D graphic-equaliser effect. class GEQ3DEffect : public EffectBase { public: const char* tags() const override { return "💫🌙📊"; } // MoonLight origin · MoonModules · audio diff --git a/src/light/effects/GEQEffect.h b/src/light/effects/GEQEffect.h index 9c2c749e..49a66108 100644 --- a/src/light/effects/GEQEffect.h +++ b/src/light/effects/GEQEffect.h @@ -31,6 +31,7 @@ namespace mm { // size. The per-column peak-fall state lives on the heap (sized to width()), allocated in onBuildState // and freed in teardown — never a large inline member. // Author: Andrew Tuline (WLED-SR) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h +/// Audio-reactive graphic-equaliser effect: 16 bands as vertical bars. class GEQEffect : public EffectBase { public: const char* tags() const override { return "💫🐙📊"; } // MoonLight origin · 2D · audio diff --git a/src/light/effects/GameOfLifeEffect.h b/src/light/effects/GameOfLifeEffect.h index 3c2c74be..6f327422 100644 --- a/src/light/effects/GameOfLifeEffect.h +++ b/src/light/effects/GameOfLifeEffect.h @@ -28,6 +28,7 @@ namespace mm { // EffectBase + shared primitives (Random8, colorFromPalette, draw::, crc16). Conway's Game of Life // (John Conway, 1970) is the underlying automaton. // Author: Ewoud Wijma (2022), modifications by Brandon Butler / @Brandon502 / wildcats08 — https://natureofcode.com/book/chapter-7-cellular-automata/ , https://github.com/DougHaber/nlife-color , https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h +/// Conway's Game of Life cellular-automaton effect. class GameOfLifeEffect : public EffectBase { public: const char* tags() const override { return "💫🌙"; } // MoonLight origin · MoonModules diff --git a/src/light/effects/LavaLampEffect.h b/src/light/effects/LavaLampEffect.h index bf023a10..8bc5949b 100644 --- a/src/light/effects/LavaLampEffect.h +++ b/src/light/effects/LavaLampEffect.h @@ -11,6 +11,8 @@ namespace mm { // through a black → red → orange → yellow → white palette. // Distinct from MetaballsEffect (which is fast, HSV-coloured). // Author: projectMM original (metaball lava lamp) +/// Lava-lamp effect: slow rising/merging palette blobs. +/// @card LavaLampEffect.gif class LavaLampEffect : public EffectBase { public: const char* tags() const override { return "💫🦅"; } // MoonLight origin · David Jupijn / Rising Step diff --git a/src/light/effects/LinesEffect.h b/src/light/effects/LinesEffect.h index b8e0bb1c..beff5dd7 100644 --- a/src/light/effects/LinesEffect.h +++ b/src/light/effects/LinesEffect.h @@ -11,6 +11,8 @@ namespace mm { // Useful for verifying preview axis orientation: each colour names its axis. // Port of MoonLight's Lines effect via projectMM-v1/LinesEffect.h. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +/// Test effect: axis-aligned planes sweeping in sync (RGB = XYZ). +/// @card LinesEffect.png class LinesEffect : public EffectBase { public: const char* tags() const override { return "💫"; } diff --git a/src/light/effects/LissajousEffect.h b/src/light/effects/LissajousEffect.h index a039e9b1..f131f333 100644 --- a/src/light/effects/LissajousEffect.h +++ b/src/light/effects/LissajousEffect.h @@ -25,6 +25,7 @@ namespace mm { // 2·locn → 2·(size−1) remap, and the elapsed()/100 + i palette walk are reproduced exactly here, // written fresh on EffectBase + the shared draw primitives. // Author: Andrew Tuline (WLED-SR) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h +/// Parametric effect tracing a Lissajous curve across the layer. class LissajousEffect : public EffectBase { public: const char* tags() const override { return "🐙"; } // WLED-lineage diff --git a/src/light/effects/MetaballsEffect.h b/src/light/effects/MetaballsEffect.h index 7610e815..259a82e3 100644 --- a/src/light/effects/MetaballsEffect.h +++ b/src/light/effects/MetaballsEffect.h @@ -8,6 +8,8 @@ namespace mm { // Author: projectMM original (metaballs) +/// Metaballs effect: smooth merging blobs via a scalar field. +/// @card MetaballsEffect.png class MetaballsEffect : public EffectBase { public: const char* tags() const override { return "💫🦅"; } // MoonLight origin · David Jupijn / Rising Step diff --git a/src/light/effects/NetworkReceiveEffect.h b/src/light/effects/NetworkReceiveEffect.h index 90320299..95137a15 100644 --- a/src/light/effects/NetworkReceiveEffect.h +++ b/src/light/effects/NetworkReceiveEffect.h @@ -38,6 +38,7 @@ namespace mm { // realtime UDP input (multi-port + per-packet validation, ArtPollReply), and // projectMM v1's ArtNetInModule. // Author: projectMM original (E1.31 / Art-Net receive) +/// Effect that paints the layer from received Art-Net/E1.31/DDP pixels. class NetworkReceiveEffect : public EffectBase { public: const char* tags() const override { return "📡🌙"; } // network input · MoonLight / v1 lineage diff --git a/src/light/effects/Noise2DEffect.h b/src/light/effects/Noise2DEffect.h index 8f6f68c8..632feeb4 100644 --- a/src/light/effects/Noise2DEffect.h +++ b/src/light/effects/Noise2DEffect.h @@ -26,6 +26,7 @@ namespace mm { // per-pixel coordinate-scale + time-on-Z animation and the direct palette indexing are reproduced // exactly here, written fresh on EffectBase + the shared draw / noise primitives. // Author: WLED (Noise 2D) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h +/// 2D value-noise effect. class Noise2DEffect : public EffectBase { public: const char* tags() const override { return "💫🌙🐙"; } // MoonLight origin · MoonModules · 2D diff --git a/src/light/effects/NoiseEffect.h b/src/light/effects/NoiseEffect.h index 48a0c78f..b562dacb 100644 --- a/src/light/effects/NoiseEffect.h +++ b/src/light/effects/NoiseEffect.h @@ -7,6 +7,8 @@ namespace mm { // Author: FastLED inoise field (Mark Kriegsman) +/// Smooth animated value-noise effect (3D on volumetric layouts). +/// @card NoiseEffect.gif class NoiseEffect : public EffectBase { public: const char* tags() const override { return "⚡️"; } // FastLED-style noise diff --git a/src/light/effects/NoiseMeterEffect.h b/src/light/effects/NoiseMeterEffect.h index f280bf66..4824d9aa 100644 --- a/src/light/effects/NoiseMeterEffect.h +++ b/src/light/effects/NoiseMeterEffect.h @@ -27,6 +27,7 @@ namespace mm { // palette / noise / beatsin8 primitives. Reads AudioModule::latestFrame(); silence → level 0 → // maxLen 0 → the panel fades to dark, safe on any target and grid size. // Author: Andrew Tuline (WLED-SR) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h +/// Audio-reactive effect: a noise field modulated by sound level. class NoiseMeterEffect : public EffectBase { public: const char* tags() const override { return "🐙📊"; } // WLED origin · audio diff --git a/src/light/effects/PaintBrushEffect.h b/src/light/effects/PaintBrushEffect.h index bc783561..9c182301 100644 --- a/src/light/effects/PaintBrushEffect.h +++ b/src/light/effects/PaintBrushEffect.h @@ -22,6 +22,7 @@ namespace mm { // fades to dark, safe on any target and any grid size. The 'soft' anti-alias control is omitted // (the one approved omission — draw::line is crisp, projectMM has no Xiaolin-Wu line yet). // Author: @TroyHacks (WLED MoonModules, GPLv3) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h +/// Effect that paints moving brush-stroke lines across the layer. class PaintBrushEffect : public EffectBase { public: const char* tags() const override { return "💫🌙📊"; } // MoonLight origin · MoonModules · audio diff --git a/src/light/effects/ParticlesEffect.h b/src/light/effects/ParticlesEffect.h index 99405660..ecd72b98 100644 --- a/src/light/effects/ParticlesEffect.h +++ b/src/light/effects/ParticlesEffect.h @@ -10,6 +10,8 @@ namespace mm { // Author: WildCats08 / @Brandon502 (MoonLight) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +/// Particle-system effect with spawned, moving points. +/// @card ParticlesEffect.png class ParticlesEffect : public EffectBase { public: const char* tags() const override { return "💫🦅"; } // MoonLight origin · David Jupijn / Rising Step diff --git a/src/light/effects/PlasmaEffect.h b/src/light/effects/PlasmaEffect.h index 8ff89a32..f2fee2db 100644 --- a/src/light/effects/PlasmaEffect.h +++ b/src/light/effects/PlasmaEffect.h @@ -8,6 +8,8 @@ namespace mm { // Author: classic plasma, FastLED / WLED lineage +/// Plasma effect: summed sine waves forming rolling blobs. +/// @card PlasmaEffect.gif class PlasmaEffect : public EffectBase { public: const char* tags() const override { return "💫🦅"; } // MoonLight origin · David Jupijn / Rising Step diff --git a/src/light/effects/PraxisEffect.h b/src/light/effects/PraxisEffect.h index a21c9fe0..15cdcce8 100644 --- a/src/light/effects/PraxisEffect.h +++ b/src/light/effects/PraxisEffect.h @@ -26,6 +26,7 @@ namespace mm { // takes the current time (elapsed()) as its second argument, matching the lib8tion shape // (bpm, timebase) with the time source threaded in at the domain edge. // Author: MONSOONO / @Flavourdynamics (MoonLight) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +/// Algorithmic palette-pattern effect driven by two beat oscillators. class PraxisEffect : public EffectBase { public: const char* tags() const override { return "💫"; } // MoonLight origin diff --git a/src/light/effects/RainbowEffect.h b/src/light/effects/RainbowEffect.h index 6aab6de4..4eaa0df3 100644 --- a/src/light/effects/RainbowEffect.h +++ b/src/light/effects/RainbowEffect.h @@ -7,6 +7,8 @@ namespace mm { // Author: FastLED rainbow (Mark Kriegsman) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_FastLED.h +/// Palette-cycling diagonal rainbow effect — the default/test effect. +/// @card RainbowEffect.png class RainbowEffect : public EffectBase { public: const char* tags() const override { return "💫"; } // MoonLight origin diff --git a/src/light/effects/RandomEffect.h b/src/light/effects/RandomEffect.h index c871de34..c6d8599c 100644 --- a/src/light/effects/RandomEffect.h +++ b/src/light/effects/RandomEffect.h @@ -20,6 +20,7 @@ namespace mm { // chosen by a flat light index across all nrOfLights (the engine's native ordering, the direct // equivalent of MoonLight's index-based setRGB), so it can land anywhere in a 1D/2D/3D layer. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +/// Effect that fills the layer with animated random colours. class RandomEffect : public EffectBase { public: const char* tags() const override { return "💫"; } // MoonLight origin diff --git a/src/light/effects/RingsEffect.h b/src/light/effects/RingsEffect.h index 9df1e7ee..8c349647 100644 --- a/src/light/effects/RingsEffect.h +++ b/src/light/effects/RingsEffect.h @@ -13,6 +13,8 @@ namespace mm { // (Renamed from RipplesEffect: the Ripples name now holds the MoonLight // sine-wave water-surface port; this concentric-rings effect is Rings.) // Author: projectMM original (concentric rings) +/// Effect of expanding concentric rings from random centres. +/// @card RingsEffect.gif class RingsEffect : public EffectBase { public: const char* tags() const override { return "💫🦅"; } // MoonLight origin · David Jupijn / Rising Step diff --git a/src/light/effects/RipplesEffect.h b/src/light/effects/RipplesEffect.h index 86daa11f..f519d475 100644 --- a/src/light/effects/RipplesEffect.h +++ b/src/light/effects/RipplesEffect.h @@ -23,6 +23,8 @@ namespace mm { // the hot-path integer-math preference is for per-light colour work, not the // handful of transcendental ops a wave front needs. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +/// Water-ripple effect: distance-from-centre drives a wave phase. +/// @card RipplesEffect.gif class RipplesEffect : public EffectBase { public: const char* tags() const override { return "💫🟦🦅"; } // MoonLight origin · water-ripple diff --git a/src/light/effects/RubiksCubeEffect.h b/src/light/effects/RubiksCubeEffect.h index f1b2a6d0..05877c8f 100644 --- a/src/light/effects/RubiksCubeEffect.h +++ b/src/light/effects/RubiksCubeEffect.h @@ -28,6 +28,7 @@ namespace mm { // in-bounds voxel is treated as mapped (the source's isMapped()-skip and the mapping-driven // sizeX++/sizeY++/sizeZ++ adjustments are dropped; the projection uses sizeX = max(size.x-1, 1)). // Author: WildCats08 / @Brandon502 (MoonLight) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +/// Effect rendering a rotating Rubik's cube on a 3D layout. class RubiksCubeEffect : public EffectBase { public: const char* tags() const override { return "💫🧊"; } // MoonLight origin · 3D-native diff --git a/src/light/effects/SineEffect.h b/src/light/effects/SineEffect.h index d7f2a462..ba8aad54 100644 --- a/src/light/effects/SineEffect.h +++ b/src/light/effects/SineEffect.h @@ -18,6 +18,7 @@ namespace mm { // Prior art: projectMM v1/v2 SineEffect (same 3D sine; those used float sinf and a // KvStore brightness publish we don't carry). // Author: MoonLight (Sinus, AI-generated) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +/// Effect of a moving sine wave across the layer. class SineEffect : public EffectBase { public: const char* tags() const override { return "🌀"; } diff --git a/src/light/effects/SolidEffect.h b/src/light/effects/SolidEffect.h index d9a80b79..aad4c2f6 100644 --- a/src/light/effects/SolidEffect.h +++ b/src/light/effects/SolidEffect.h @@ -27,6 +27,7 @@ namespace mm { // MoonLight's per-entry scan one-for-one. The optional white channel is written only when the layer // carries a 4th channel (channelsPerLight() >= 4); on RGB layers the white member is ignored. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +/// Effect that fills the whole layer with one palette colour. class SolidEffect : public EffectBase { public: const char* tags() const override { return "💫"; } // MoonLight origin diff --git a/src/light/effects/SphereMoveEffect.h b/src/light/effects/SphereMoveEffect.h index 05c220e2..3bb98166 100644 --- a/src/light/effects/SphereMoveEffect.h +++ b/src/light/effects/SphereMoveEffect.h @@ -27,6 +27,7 @@ namespace mm { // expression) so the origin sweep and diameter breathing integrate continuously rather than in // quantised integer-time steps — a smooth sweep at every speed. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +/// Effect moving a lit sphere through a 3D layout. class SphereMoveEffect : public EffectBase { public: const char* tags() const override { return "💫🧊"; } // MoonLight origin · 3D-native diff --git a/src/light/effects/SpiralEffect.h b/src/light/effects/SpiralEffect.h index 2c8d84b1..8e85804f 100644 --- a/src/light/effects/SpiralEffect.h +++ b/src/light/effects/SpiralEffect.h @@ -8,6 +8,8 @@ namespace mm { // Author: projectMM original (rotating spiral) +/// Effect winding a lit spiral up a conical layout. +/// @card SpiralEffect.png class SpiralEffect : public EffectBase { public: const char* tags() const override { return "💫🦅"; } // MoonLight origin · David Jupijn / Rising Step diff --git a/src/light/effects/StarFieldEffect.h b/src/light/effects/StarFieldEffect.h index 995b5ba5..3aa5ba28 100644 --- a/src/light/effects/StarFieldEffect.h +++ b/src/light/effects/StarFieldEffect.h @@ -31,6 +31,7 @@ namespace mm { // + the shared draw/palette primitives. The star table lives on the heap (platform::alloc), sized // to the control maximum, rather than as a large inline member. // Author: @Brandon502 (MoonLight), inspired by Daniel Shiffman / Coding Train — https://www.youtube.com/watch?v=17WoOqgXsRM , https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +/// Star-field effect: drifting points like flying through stars. class StarFieldEffect : public EffectBase { public: const char* tags() const override { return "💫"; } // MoonLight origin diff --git a/src/light/effects/StarSkyEffect.h b/src/light/effects/StarSkyEffect.h index 2f965514..0ee34cd3 100644 --- a/src/light/effects/StarSkyEffect.h +++ b/src/light/effects/StarSkyEffect.h @@ -24,6 +24,8 @@ namespace mm { // colorFromPalette, draw::). The per-star arrays live on the heap (platform::alloc), never as inline // members, so sizeof(StarSkyEffect) stays tiny. // Author: limpkin (MoonLight) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +/// Night-sky effect: twinkling stars over a dark field. +/// @card StarSkyEffect.gif class StarSkyEffect : public EffectBase { public: const char* tags() const override { return "💫"; } // MoonLight origin diff --git a/src/light/effects/TetrixEffect.h b/src/light/effects/TetrixEffect.h index 0bf7d48c..8ca129ad 100644 --- a/src/light/effects/TetrixEffect.h +++ b/src/light/effects/TetrixEffect.h @@ -23,6 +23,7 @@ namespace mm { // MoonLight spec, written fresh on EffectBase + the shared draw primitives. One drop per X column; // safe at any grid size. // Author: Andrew Tuline (WLED-SR) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h +/// Tetris-style effect: falling, stacking blocks. class TetrixEffect : public EffectBase { public: const char* tags() const override { return "💫🌙"; } // MoonLight origin · MoonModules diff --git a/src/light/effects/TextEffect.h b/src/light/effects/TextEffect.h index 6e04b08c..bf720aaa 100644 --- a/src/light/effects/TextEffect.h +++ b/src/light/effects/TextEffect.h @@ -26,6 +26,7 @@ namespace mm { // on projectMM's EffectBase + the shared draw/font primitives. (MoonLight's IP/FPS/uptime presets are // a separate follow-up — see backlog — kept out so a light effect doesn't reach into system state.) // Author: projectMM original, on MoonLight's Scrolling Text — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +/// Effect rendering a scrolling multi-line string in a bitmap font. class TextEffect : public EffectBase { public: const char* tags() const override { return "💫"; } // MoonLight origin diff --git a/src/light/effects/WaveEffect.h b/src/light/effects/WaveEffect.h index be7fc3e9..32c27747 100644 --- a/src/light/effects/WaveEffect.h +++ b/src/light/effects/WaveEffect.h @@ -27,6 +27,7 @@ namespace mm { // selected waveform to a y in [0, height); that pixel is lit, and for the discontinuous shapes // (sawtooth, square) a vertical segment connects to the previous column so the line stays joined. // Author: Ewoud Wijma (MoonLight) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +/// Effect of a travelling wave across the layer. class WaveEffect : public EffectBase { public: const char* tags() const override { return "🌊"; } diff --git a/src/light/layers/Layer.h b/src/light/layers/Layer.h index bc919203..627b0dbd 100644 --- a/src/light/layers/Layer.h +++ b/src/light/layers/Layer.h @@ -31,6 +31,7 @@ namespace mm { /// **Status:** the status slot shows the LOGICAL box the effects render into (`` `<w>×<h>×<d>` ``), which can differ from the physical box shown on `Layouts` (a Mirror-XY modifier folds a 128×128 physical layout into a 64×64 logical box). The same slot carries memory-degradation warnings when a build can't fit (`modifier mapping skipped`, `buffer reduced`, `buffer allocation failed`, all `— not enough memory`), and a warning wins over the neutral box line. /// /// **Prior art:** MoonLight's `VirtualLayer` — `oneToOneMapping` fast-path flag, `virtualChannels` per-layer buffer, `effectDimension`, a `nodes` vector for effects/modifiers, and `forEachLight` per-logical-light iteration that asks the modifier for physical destinations (https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/VirtualLayer.h). +/// @card Layer.png class Layer : public MoonModule { public: ModuleRole role() const override { return ModuleRole::Layer; } diff --git a/src/light/layers/Layers.h b/src/light/layers/Layers.h index 3cfe590e..1470b411 100644 --- a/src/light/layers/Layers.h +++ b/src/light/layers/Layers.h @@ -14,6 +14,7 @@ namespace mm { /// **No buffer of its own:** each Layer owns its buffer and the `Drivers` container owns the composited output. Layers wires the shared `Layouts` into every child so each can size its buffer. Two queries serve the Drivers compositor: `activeLayer` (the first enabled child, or a disabled one as fallback) answers physical dimensions and feeds the single-layer fast path, and `forEachEnabledLayer` walks the enabled children in container order (bottom→top) marking the bottom layer that clears the buffer. `enabledLayerCount` lets Drivers pick the fast path (one enabled layer → hand its buffer straight to the driver) versus the composite path (≥2 → blend into the output buffer). /// /// **Prior art:** MoonLight's `PhysicalLayer` runs N `VirtualLayer`s and composites their buffers into the display channel — same idea, different shape: Drivers (not Layers) does the compositing here (https://github.com/ewowi/MoonLight/blob/main/src/MoonLight). +/// @card Layers.png class Layers : public MoonModule { public: const char* acceptsChildRoles() const override { return "layer"; } diff --git a/src/light/layouts/CarLightsLayout.h b/src/light/layouts/CarLightsLayout.h index 8d820f28..eedd4d48 100644 --- a/src/light/layouts/CarLightsLayout.h +++ b/src/light/layouts/CarLightsLayout.h @@ -3,7 +3,7 @@ #include <cmath> // sinf, cosf, fmodf #include <cstdint> #include <numbers> // std::numbers::pi_v — portable pi -#include "light/layouts/Layouts.h" +#include "light/layouts/LayoutBase.h" #include "light/light_types.h" // lengthType, nrOfLightsType namespace mm { @@ -35,6 +35,8 @@ namespace mm { // pin/wiring plumbing (nextPin / doNextPin) is dropped — a projectMM layout // emits coordinates only; the driver owns pins. // Author: projectMM / custom fixture +/// Layout mapping automotive light-strip coordinates. +/// @card CarLightsLayout.gif class CarLightsLayout : public LayoutBase { public: // Verbatim MoonLight default and range (the commented-out nrOfSpokes / diff --git a/src/light/layouts/CubeLayout.h b/src/light/layouts/CubeLayout.h index 8225624d..59768ac0 100644 --- a/src/light/layouts/CubeLayout.h +++ b/src/light/layouts/CubeLayout.h @@ -2,7 +2,7 @@ #include <limits> #include <cstdint> -#include "light/layouts/Layouts.h" +#include "light/layouts/LayoutBase.h" #include "light/light_types.h" // lengthType, nrOfLightsType namespace mm { @@ -26,6 +26,7 @@ namespace mm { // layouts emit coordinates only — the driver owns pins — so that plumbing is // dropped and only the geometry is kept. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h +/// Layout of a 3D cube volume (width×height×depth). class CubeLayout : public LayoutBase { public: // Cube edges. Defaults 10×10×10, range 1..128 — MoonLight's exact defaults. diff --git a/src/light/layouts/GridLayout.h b/src/light/layouts/GridLayout.h index 9ad969a4..3a430710 100644 --- a/src/light/layouts/GridLayout.h +++ b/src/light/layouts/GridLayout.h @@ -2,7 +2,7 @@ #include <limits> #include <cstdint> -#include "light/layouts/Layouts.h" +#include "light/layouts/LayoutBase.h" #include "light/light_types.h" // lengthType, nrOfLightsType namespace mm { @@ -14,6 +14,8 @@ namespace mm { constexpr lengthType defaultGridSize = 16; // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h +/// Layout of a dense row-major 3D grid. +/// @card GridLayout.png class GridLayout : public LayoutBase { public: lengthType width = defaultGridSize; diff --git a/src/light/layouts/HumanSizedCubeLayout.h b/src/light/layouts/HumanSizedCubeLayout.h index 73415ebd..662b8c8b 100644 --- a/src/light/layouts/HumanSizedCubeLayout.h +++ b/src/light/layouts/HumanSizedCubeLayout.h @@ -2,7 +2,7 @@ #include <limits> #include <cstdint> -#include "light/layouts/Layouts.h" +#include "light/layouts/LayoutBase.h" #include "light/light_types.h" // lengthType, nrOfLightsType namespace mm { @@ -24,6 +24,7 @@ namespace mm { // layout emits coordinates only (the driver owns pins), and the sixth face // is disabled in the source. tags 💫 marks the MoonLight lineage. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h +/// Layout of a walk-in cube built from five LED-curtain faces. class HumanSizedCubeLayout : public LayoutBase { public: // Verbatim MoonLight defaults and ranges: a 10×10×10 cube, each edge 1..20. diff --git a/src/light/layouts/LayoutBase.h b/src/light/layouts/LayoutBase.h new file mode 100644 index 00000000..59cb0abe --- /dev/null +++ b/src/light/layouts/LayoutBase.h @@ -0,0 +1,28 @@ +#pragma once + +#include "core/MoonModule.h" +#include "light/light_types.h" // lengthType, nrOfLightsType + +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. +using CoordCallback = void(*)(void* ctx, nrOfLightsType idx, lengthType x, lengthType y, lengthType 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 +/// control change triggers the pipeline-wide rebuild. +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; + + /// Every layout control (grid width/height/depth, …) changes the physical light + /// count and therefore needs the pipeline-wide rebuild. See MoonModule::onUpdate. + bool controlChangeTriggersBuildState(const char* /*controlName*/) const override { return true; } +}; + +} // namespace mm diff --git a/src/light/layouts/Layouts.h b/src/light/layouts/Layouts.h index cbeae521..5abf6d4b 100644 --- a/src/light/layouts/Layouts.h +++ b/src/light/layouts/Layouts.h @@ -1,5 +1,6 @@ #pragma once +#include "light/layouts/LayoutBase.h" // LayoutBase + CoordCallback — the Layouts container casts its children to it #include "core/MoonModule.h" #include "light/light_types.h" // lengthType, nrOfLightsType @@ -7,25 +8,6 @@ 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. -using CoordCallback = void(*)(void* ctx, nrOfLightsType idx, lengthType x, lengthType y, lengthType 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 -/// control change triggers the pipeline-wide rebuild. -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; - - /// Every layout control (grid width/height/depth, …) changes the physical light - /// count and therefore needs the pipeline-wide rebuild. See MoonModule::onUpdate. - bool controlChangeTriggersBuildState(const char* /*controlName*/) const override { return true; } -}; /// Top-level container for one or more `LayoutBase` children — it defines the physical light topology of the installation and is shared by every Layer in the `Layers` container (one Layouts describing the physical setup, multiple Layers render into it). /// @@ -36,6 +18,7 @@ class LayoutBase : public MoonModule { /// **Reordering:** layout children reorder by drag-and-drop (`POST /api/modules/<name>/move` with `{"to": <index>}`), with insert (not swap) semantics — the standard reorderable-list behaviour (Finder, Trello, SortableJS). Order sets the physical index range each layout occupies, which drives ArtNet universe assignment. The same `move` op applies to every container. /// /// **Status:** the status slot shows the physical setup it describes — `` `<N> lights · <W>×<H>×<D>` `` — the total light count summed across enabled children (the driver output buffer size) and the physical bounding box (the extent of all light coordinates, the dense render buffer size). For a dense grid the count equals the box volume; for a sparse layout (a sphere shell) the count is smaller than the box, and that gap is the at-a-glance signal that the layout is sparse. An empty setup reports Warning severity. Recomputed on every rebuild, not per tick. +/// @card Layouts.png class Layouts : public MoonModule { public: const char* acceptsChildRoles() const override { return "layout"; } diff --git a/src/light/layouts/PanelLayout.h b/src/light/layouts/PanelLayout.h index f9bb7b80..be9a3a4c 100644 --- a/src/light/layouts/PanelLayout.h +++ b/src/light/layouts/PanelLayout.h @@ -2,7 +2,7 @@ #include <limits> #include <cstdint> -#include "light/layouts/Layouts.h" +#include "light/layouts/LayoutBase.h" #include "light/light_types.h" // lengthType, nrOfLightsType namespace mm { @@ -24,6 +24,7 @@ namespace mm { // the control labels/defaults; the reconstructed logic is marked // RECONSTRUCTED // and cross-checks against GridLayout's serpentine on the defaults. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h +/// Layout of a single 2D LED panel. class PanelLayout : public LayoutBase { public: // Geometry (verbatim MoonLight defaults): a 16×16 panel. diff --git a/src/light/layouts/PanelsLayout.h b/src/light/layouts/PanelsLayout.h index 5e9a8a52..3e4b6b37 100644 --- a/src/light/layouts/PanelsLayout.h +++ b/src/light/layouts/PanelsLayout.h @@ -2,7 +2,7 @@ #include <limits> #include <cstdint> -#include "light/layouts/Layouts.h" +#include "light/layouts/LayoutBase.h" #include "light/light_types.h" // lengthType, nrOfLightsType namespace mm { @@ -32,6 +32,7 @@ namespace mm { // reconstruction. The reconstructed logic is marked // RECONSTRUCTED and, on the // defaults, cross-checks against a plain serpentine tiling. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h +/// Layout tiling multiple panels into one grid. class PanelsLayout : public LayoutBase { public: // Outer level — the panel grid (MoonLight `panels`, default 2×2 panels). diff --git a/src/light/layouts/RingLayout.h b/src/light/layouts/RingLayout.h index aaf8f42c..63606ee0 100644 --- a/src/light/layouts/RingLayout.h +++ b/src/light/layouts/RingLayout.h @@ -3,7 +3,7 @@ #include <cmath> // sinf, cosf, fmodf #include <cstdint> #include <numbers> // std::numbers::pi_v — portable pi (M_PI is a non-standard <cmath> extension) -#include "light/layouts/Layouts.h" +#include "light/layouts/LayoutBase.h" #include "light/light_types.h" // lengthType, nrOfLightsType namespace mm { @@ -24,6 +24,7 @@ namespace mm { // Float trig runs on the cold build path (forEachCoord / lightCount, called from a // rebuild), never the hot render loop, so it's allowed here. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h +/// Layout of a single ring of evenly-spaced LEDs. class RingLayout : public LayoutBase { public: // MoonLight defaults and ranges, preserved verbatim. diff --git a/src/light/layouts/Rings241Layout.h b/src/light/layouts/Rings241Layout.h index 329dec4e..882299ca 100644 --- a/src/light/layouts/Rings241Layout.h +++ b/src/light/layouts/Rings241Layout.h @@ -2,7 +2,7 @@ #include <cstdint> #include <cmath> // std::sin, std::cos on float — cold build-path ring trig -#include "light/layouts/Layouts.h" +#include "light/layouts/LayoutBase.h" #include "light/light_types.h" // lengthType, nrOfLightsType namespace mm { @@ -30,6 +30,7 @@ namespace mm { // MoonLight by one unit. The faithful path is MoonLight's own: form the angle in // double → narrow to float → float trig → float radius. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h +/// Layout of the 241-LED concentric-rings disc. class Rings241Layout : public LayoutBase { public: // Spacing multiplier — scales both the ring radii and the shared centre. diff --git a/src/light/layouts/SingleColumnLayout.h b/src/light/layouts/SingleColumnLayout.h index c24debe9..37b910f8 100644 --- a/src/light/layouts/SingleColumnLayout.h +++ b/src/light/layouts/SingleColumnLayout.h @@ -2,7 +2,7 @@ #include <limits> #include <cstdint> -#include "light/layouts/Layouts.h" +#include "light/layouts/LayoutBase.h" #include "light/light_types.h" // lengthType, nrOfLightsType // Prior art: MoonLight SingleColumnLayout (MoonModules/MoonLight, layout nodes). @@ -15,6 +15,7 @@ namespace mm { // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h +/// Layout of one vertical LED column (1D). class SingleColumnLayout : public LayoutBase { public: // Geometry controls mirror MoonLight's defaults and ranges 1:1. diff --git a/src/light/layouts/SingleRowLayout.h b/src/light/layouts/SingleRowLayout.h index 53e796fa..d66d6ac1 100644 --- a/src/light/layouts/SingleRowLayout.h +++ b/src/light/layouts/SingleRowLayout.h @@ -1,7 +1,7 @@ #pragma once #include <cstdint> -#include "light/layouts/Layouts.h" +#include "light/layouts/LayoutBase.h" #include "light/light_types.h" // lengthType, nrOfLightsType namespace mm { @@ -18,6 +18,7 @@ namespace mm { // ledPinDIO select, its "LED NN" pin menu, and nextPin()): a projectMM layout // emits coordinates only, the driver owns pin assignment. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h +/// Layout of one horizontal LED row (1D). class SingleRowLayout : public LayoutBase { public: const char* tags() const override { return "💫"; } // MoonLight origin diff --git a/src/light/layouts/SphereLayout.h b/src/light/layouts/SphereLayout.h index d9720cc1..971c7603 100644 --- a/src/light/layouts/SphereLayout.h +++ b/src/light/layouts/SphereLayout.h @@ -1,7 +1,7 @@ #pragma once #include <cstdint> -#include "light/layouts/Layouts.h" +#include "light/layouts/LayoutBase.h" #include "light/light_types.h" // lengthType, nrOfLightsType namespace mm { @@ -18,6 +18,7 @@ namespace mm { // though layout iteration is a cold path, because the same pattern reads // uniformly across the codebase. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h +/// Layout mapping LEDs onto a sphere surface. class SphereLayout : public LayoutBase { public: // Surface radius in light-units. Min 1 (the smallest hollow sphere: 18 diff --git a/src/light/layouts/SpiralLayout.h b/src/light/layouts/SpiralLayout.h index 249c5111..5052de9a 100644 --- a/src/light/layouts/SpiralLayout.h +++ b/src/light/layouts/SpiralLayout.h @@ -3,7 +3,7 @@ #include <cmath> // sinf, cosf #include <cstdint> #include <limits> // std::numeric_limits (lightCount clamp, matches GridLayout) -#include "light/layouts/Layouts.h" +#include "light/layouts/LayoutBase.h" #include "light/light_types.h" // lengthType, nrOfLightsType namespace mm { @@ -24,6 +24,7 @@ namespace mm { // MoonLight's per-strip pin plumbing (nextPin) is dropped: a projectMM layout // emits coordinates only; the driver owns wiring. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h +/// Layout winding LEDs up a conical spiral. class SpiralLayout : public LayoutBase { public: lengthType ledCount = 640; // total lights along the spiral diff --git a/src/light/layouts/TorontoBarGourdsLayout.h b/src/light/layouts/TorontoBarGourdsLayout.h index 9280cf18..098bcac4 100644 --- a/src/light/layouts/TorontoBarGourdsLayout.h +++ b/src/light/layouts/TorontoBarGourdsLayout.h @@ -1,7 +1,7 @@ #pragma once #include <cstdint> -#include "light/layouts/Layouts.h" +#include "light/layouts/LayoutBase.h" #include "light/light_types.h" // lengthType, nrOfLightsType, Coord3D namespace mm { @@ -31,6 +31,8 @@ namespace mm { // count and the emitted set can never disagree (the RingLayout/SphereLayout // pattern). Integer math throughout; this is the cold build path. // Author: projectMM / custom fixture +/// Layout for the Toronto bar decorative-gourd installation. +/// @card TorontoBarGourdsLayout.gif class TorontoBarGourdsLayout : public LayoutBase { public: // Mode 0 only: how many stacked lights one gourd contributes. MoonLight diff --git a/src/light/layouts/TubesLayout.h b/src/light/layouts/TubesLayout.h index 1ee3edc0..e6c43434 100644 --- a/src/light/layouts/TubesLayout.h +++ b/src/light/layouts/TubesLayout.h @@ -2,7 +2,7 @@ #include <cstdint> #include <limits> -#include "light/layouts/Layouts.h" +#include "light/layouts/LayoutBase.h" #include "light/light_types.h" // lengthType, nrOfLightsType namespace mm { @@ -19,6 +19,7 @@ namespace mm { // driver owns pin assignment, so MoonLight's per-column nextPin() plumbing is // dropped. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h +/// Layout of parallel LED tubes. class TubesLayout : public LayoutBase { public: // Defaults verbatim from MoonLight (nrOfTubes 4, ledsPerTube 54, diff --git a/src/light/layouts/WheelLayout.h b/src/light/layouts/WheelLayout.h index 27931d20..da89e814 100644 --- a/src/light/layouts/WheelLayout.h +++ b/src/light/layouts/WheelLayout.h @@ -1,7 +1,7 @@ #pragma once #include <cstdint> -#include "light/layouts/Layouts.h" +#include "light/layouts/LayoutBase.h" #include "light/light_types.h" // lengthType, nrOfLightsType #include "core/math8.h" // sin8, cos8 — integer trig LUT @@ -22,6 +22,7 @@ namespace mm { // Prior art: MoonLight ring/spoke layouts (L_MoonLight.h); projectMM v2 WheelLayoutModule // (those used double cos/sin/round — this is the integer-LUT equivalent). // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h +/// Layout of LEDs around a wheel/disc. class WheelLayout : public LayoutBase { public: uint16_t spokes = 8; // number of spokes, 2..64 diff --git a/src/light/modifiers/BlockModifier.h b/src/light/modifiers/BlockModifier.h index 6ebf600f..32dd1ab4 100644 --- a/src/light/modifiers/BlockModifier.h +++ b/src/light/modifiers/BlockModifier.h @@ -25,6 +25,7 @@ namespace mm { // our fold interface: modifySize() -> modifyLogicalSize (stashing the incoming box), // modifyPosition() -> the const modifyLogical fold that reads the stash. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h +/// Modifier expanding a 1D effect into concentric square rings. class BlockModifier : public ModifierBase { public: const char* tags() const override { return "💫"; } // MoonLight origin diff --git a/src/light/modifiers/CheckerboardModifier.h b/src/light/modifiers/CheckerboardModifier.h index 54096981..74f98d43 100644 --- a/src/light/modifiers/CheckerboardModifier.h +++ b/src/light/modifiers/CheckerboardModifier.h @@ -12,6 +12,8 @@ namespace mm { // Prior art: MoonLight's Checkerboard modifier (M_MoonLight.h) drops lights by // setting position to a sentinel; our fold returns false from modifyLogical. // Author: WildCats08 / @Brandon502 (MoonLight) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h +/// Modifier masking the layer in a checkerboard pattern. +/// @card CheckerboardModifier.gif class CheckerboardModifier : public ModifierBase { public: const char* tags() const override { return "💫"; } // MoonLight origin diff --git a/src/light/modifiers/CircleModifier.h b/src/light/modifiers/CircleModifier.h index 02dbcffb..1254311c 100644 --- a/src/light/modifiers/CircleModifier.h +++ b/src/light/modifiers/CircleModifier.h @@ -25,6 +25,7 @@ namespace mm { // fresh against our ModifierBase fold interface (modifyLogicalSize / modifyLogical) // rather than MoonLight's modifySize / modifyPosition Node API. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h +/// Modifier expanding a 1D effect into concentric circular rings. class CircleModifier : public ModifierBase { public: const char* tags() const override { return "💫"; } // MoonLight origin diff --git a/src/light/modifiers/MirrorModifier.h b/src/light/modifiers/MirrorModifier.h index 939b2b32..6330e2ae 100644 --- a/src/light/modifiers/MirrorModifier.h +++ b/src/light/modifiers/MirrorModifier.h @@ -23,6 +23,8 @@ namespace mm { // modifyLogicalSize()/modifyLogical(), and the box is stashed for the const fold // via the `modifierSize` pattern the base class documents. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h +/// Modifier folding each box axis onto itself (mirror). +/// @card MirrorModifier.gif class MirrorModifier : public ModifierBase { public: const char* tags() const override { return "💫"; } // MoonLight origin diff --git a/src/light/modifiers/MultiplyModifier.h b/src/light/modifiers/MultiplyModifier.h index 7d3d2fa9..874cd8ff 100644 --- a/src/light/modifiers/MultiplyModifier.h +++ b/src/light/modifiers/MultiplyModifier.h @@ -18,6 +18,8 @@ namespace mm { // mirror bools (3) instead of MoonLight's single mirror flag, and per-axis // multipliers, so X/Y/Z can fold and tile independently. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h +/// Modifier tiling the image across the box, optionally mirrored. +/// @card MultiplyModifier.png class MultiplyModifier : public ModifierBase { public: const char* tags() const override { return "💫"; } // MoonLight origin diff --git a/src/light/modifiers/PinwheelModifier.h b/src/light/modifiers/PinwheelModifier.h index 398c9de8..ce5f52cd 100644 --- a/src/light/modifiers/PinwheelModifier.h +++ b/src/light/modifiers/PinwheelModifier.h @@ -24,6 +24,7 @@ namespace mm { // emits coordinates only. Float math (atan2/hypot/sqrt) runs on the build path // (modifyLogical is called at rebuild, not in the hot render loop). // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h +/// Modifier remapping the grid into radial petals. class PinwheelModifier : public ModifierBase { public: const char* tags() const override { return "💫"; } // MoonLight origin diff --git a/src/light/modifiers/RandomMapModifier.h b/src/light/modifiers/RandomMapModifier.h index 72085dea..eeb8da40 100644 --- a/src/light/modifiers/RandomMapModifier.h +++ b/src/light/modifiers/RandomMapModifier.h @@ -27,6 +27,7 @@ namespace mm { // Sparse layouts: the permutation is over box indices, so a real light can map to a // non-light cell (dropped → dark). Acceptable for v1. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h +/// Modifier remapping every light through a random 1:1 permutation. class RandomMapModifier : public ModifierBase { public: uint8_t bpm = 6; // reshuffles per minute (0–60); 6 ≈ every 10 s; 0 = frozen diff --git a/src/light/modifiers/RegionModifier.h b/src/light/modifiers/RegionModifier.h index 4e3620b6..a4b147f3 100644 --- a/src/light/modifiers/RegionModifier.h +++ b/src/light/modifiers/RegionModifier.h @@ -36,6 +36,7 @@ namespace mm { // its identity/memcpy path with zero carving cost. Adding a full-region (0/100) // RegionModifier is correct but not free; the default is to not add one. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h +/// Modifier carving the layer to a percentage sub-rectangle. class RegionModifier : public ModifierBase { public: lengthType startX = 0, startY = 0, startZ = 0; diff --git a/src/light/modifiers/RippleXZModifier.h b/src/light/modifiers/RippleXZModifier.h index 49498fd0..69740e9b 100644 --- a/src/light/modifiers/RippleXZModifier.h +++ b/src/light/modifiers/RippleXZModifier.h @@ -22,6 +22,8 @@ namespace mm { // propagation is dropped here (the geometry — the axis collapse — is preserved // exactly). Defaults match MoonLight: shrink=true, towardsX=true, towardsZ=false. // Author: @Troy (WLEDMM Art-Net) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h +/// Modifier that collapses one axis of the box to a single plane, folding every coordinate on that +/// axis to 0 (the static geometry of MoonLight's RippleXZ; its buffer-shift ripple wave is omitted). class RippleXZModifier : public ModifierBase { public: const char* tags() const override { return "💫"; } // MoonLight origin diff --git a/src/light/modifiers/RotateModifier.h b/src/light/modifiers/RotateModifier.h index ed668b7d..cae84feb 100644 --- a/src/light/modifiers/RotateModifier.h +++ b/src/light/modifiers/RotateModifier.h @@ -31,6 +31,7 @@ namespace mm { // Prior art: MoonLight M_MoonLight.h Rotate (modifyXYZ per-frame transform). Same per-frame // coordinate remap; we name the hook modifyLive and carry an explicit matrix. // Author: WildCats08 / @Brandon502 (MoonLight) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h +/// Modifier rotating the 2D image about its centre over time. class RotateModifier : public ModifierBase { public: Dim dimensions() const override { return Dim::D2; } // 2D rotation (advisory chip) diff --git a/src/light/modifiers/TransposeModifier.h b/src/light/modifiers/TransposeModifier.h index 4889ce46..f9345e6d 100644 --- a/src/light/modifiers/TransposeModifier.h +++ b/src/light/modifiers/TransposeModifier.h @@ -17,6 +17,7 @@ namespace mm { // `layer->size`), reproduced here by inverting against the stashed box. Written // fresh against our fold interface. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h +/// Modifier swapping a pair of box axes. class TransposeModifier : public ModifierBase { public: const char* tags() const override { return "💫"; } // MoonLight origin diff --git a/src/light/moonlive/MoonLiveEffect.h b/src/light/moonlive/MoonLiveEffect.h index ba371a02..da869d3b 100644 --- a/src/light/moonlive/MoonLiveEffect.h +++ b/src/light/moonlive/MoonLiveEffect.h @@ -17,6 +17,7 @@ namespace mm { +/// Effect whose render is a live-authored MoonLive script. class MoonLiveEffect : public EffectBase { public: const char* tags() const override { return "📝"; } // scripted diff --git a/src/main.cpp b/src/main.cpp index 56493ac8..d7314d52 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -66,6 +66,7 @@ #include "light/modifiers/BlockModifier.h" #include "light/modifiers/PinwheelModifier.h" #include "light/modifiers/RippleXZModifier.h" +#include "light/drivers/Drivers.h" // the Drivers container (registered + wired below); driver subclasses include DriverBase.h directly #include "light/drivers/HueDriver.h" #include "light/drivers/NetworkSendDriver.h" #include "light/drivers/PreviewDriver.h" @@ -114,113 +115,113 @@ static void registerModuleTypes() { // Second argument is the module's spec page relative to docs/moonmodules/ — // the UI builds a help link from it. Effects/modifiers/leaf-layouts share one - // compact-row page per type (effects/effects.md, …); containers, drivers, and + // compact-row page per type (effects.md, …); containers, drivers, and // core modules keep a per-module page named for the type. // Containers - mm::ModuleFactory::registerType<mm::Layouts>("Layouts", "light/Layouts.md"); - mm::ModuleFactory::registerType<mm::Layers>("Layers", "light/Layers.md"); - mm::ModuleFactory::registerType<mm::Layer>("Layer", "light/Layer.md"); - mm::ModuleFactory::registerType<mm::Drivers>("Drivers", "light/Drivers.md"); + mm::ModuleFactory::registerType<mm::Layouts>("Layouts", "light/supporting.md#layouts"); + mm::ModuleFactory::registerType<mm::Layers>("Layers", "light/supporting.md#layers"); + mm::ModuleFactory::registerType<mm::Layer>("Layer", "light/supporting.md#layer"); + mm::ModuleFactory::registerType<mm::Drivers>("Drivers", "light/supporting.md#drivers"); // Concrete modules. registerType<T> captures the type's dimensions() via // if-constexpr when present — EffectBase and ModifierBase both expose one, // so the UI's 📏/🟦/🧊 chip lights up without any per-domain wrapper. // Layouts — alphabetical by display name. - mm::ModuleFactory::registerType<mm::CarLightsLayout>("CarLightsLayout", "light/layouts/layouts.md#carlights"); - mm::ModuleFactory::registerType<mm::CubeLayout>("CubeLayout", "light/layouts/layouts.md#cube"); - mm::ModuleFactory::registerType<mm::HumanSizedCubeLayout>("HumanSizedCubeLayout", "light/layouts/layouts.md#humansizedcube"); - mm::ModuleFactory::registerType<mm::PanelsLayout>("PanelsLayout", "light/layouts/layouts.md#panels"); - mm::ModuleFactory::registerType<mm::TorontoBarGourdsLayout>("TorontoBarGourdsLayout", "light/layouts/layouts.md#torontobargourds"); - mm::ModuleFactory::registerType<mm::GridLayout>("GridLayout", "light/layouts/layouts.md#grid"); - mm::ModuleFactory::registerType<mm::PanelLayout>("PanelLayout", "light/layouts/layouts.md#panel"); - mm::ModuleFactory::registerType<mm::RingLayout>("RingLayout", "light/layouts/layouts.md#ring"); - mm::ModuleFactory::registerType<mm::Rings241Layout>("Rings241Layout", "light/layouts/layouts.md#rings241"); - mm::ModuleFactory::registerType<mm::SingleColumnLayout>("SingleColumnLayout", "light/layouts/layouts.md#singlecolumn"); - mm::ModuleFactory::registerType<mm::SingleRowLayout>("SingleRowLayout", "light/layouts/layouts.md#singlerow"); - mm::ModuleFactory::registerType<mm::SphereLayout>("SphereLayout", "light/layouts/layouts.md#sphere"); - mm::ModuleFactory::registerType<mm::SpiralLayout>("SpiralLayout", "light/layouts/layouts.md#spiral"); - mm::ModuleFactory::registerType<mm::TubesLayout>("TubesLayout", "light/layouts/layouts.md#tubes"); - mm::ModuleFactory::registerType<mm::WheelLayout>("WheelLayout", "light/layouts/layouts.md#wheel"); + mm::ModuleFactory::registerType<mm::CarLightsLayout>("CarLightsLayout", "light/layouts.md#carlights"); + mm::ModuleFactory::registerType<mm::CubeLayout>("CubeLayout", "light/layouts.md#cube"); + mm::ModuleFactory::registerType<mm::HumanSizedCubeLayout>("HumanSizedCubeLayout", "light/layouts.md#humansizedcube"); + mm::ModuleFactory::registerType<mm::PanelsLayout>("PanelsLayout", "light/layouts.md#panels"); + mm::ModuleFactory::registerType<mm::TorontoBarGourdsLayout>("TorontoBarGourdsLayout", "light/layouts.md#torontobargourds"); + mm::ModuleFactory::registerType<mm::GridLayout>("GridLayout", "light/layouts.md#grid"); + mm::ModuleFactory::registerType<mm::PanelLayout>("PanelLayout", "light/layouts.md#panel"); + mm::ModuleFactory::registerType<mm::RingLayout>("RingLayout", "light/layouts.md#ring"); + mm::ModuleFactory::registerType<mm::Rings241Layout>("Rings241Layout", "light/layouts.md#rings241"); + mm::ModuleFactory::registerType<mm::SingleColumnLayout>("SingleColumnLayout", "light/layouts.md#singlecolumn"); + mm::ModuleFactory::registerType<mm::SingleRowLayout>("SingleRowLayout", "light/layouts.md#singlerow"); + mm::ModuleFactory::registerType<mm::SphereLayout>("SphereLayout", "light/layouts.md#sphere"); + mm::ModuleFactory::registerType<mm::SpiralLayout>("SpiralLayout", "light/layouts.md#spiral"); + mm::ModuleFactory::registerType<mm::TubesLayout>("TubesLayout", "light/layouts.md#tubes"); + mm::ModuleFactory::registerType<mm::WheelLayout>("WheelLayout", "light/layouts.md#wheel"); // Effects — registered alphabetically by display name (the picker + docs also sort // alphabetically; keeping this list sorted makes the three orders agree at a glance). - mm::ModuleFactory::registerType<mm::AudioSpectrumEffect>("AudioSpectrumEffect", "light/effects/effects.md#audiospectrum"); - mm::ModuleFactory::registerType<mm::AudioVolumeEffect>("AudioVolumeEffect", "light/effects/effects.md#audiovolume"); - mm::ModuleFactory::registerType<mm::BlurzEffect>("BlurzEffect", "light/effects/effects.md#blurz"); - mm::ModuleFactory::registerType<mm::BouncingBallsEffect>("BouncingBallsEffect", "light/effects/effects.md#bouncingballs"); - mm::ModuleFactory::registerType<mm::DemoReelEffect>("DemoReelEffect", "light/effects/effects.md#demoreel"); - mm::ModuleFactory::registerType<mm::DistortionWavesEffect>("DistortionWavesEffect", "light/effects/effects.md#distortionwaves"); - mm::ModuleFactory::registerType<mm::FireEffect>("FireEffect", "light/effects/effects.md#fire"); - mm::ModuleFactory::registerType<mm::FixedRectangleEffect>("FixedRectangleEffect", "light/effects/effects.md#fixedrectangle"); - mm::ModuleFactory::registerType<mm::FreqMatrixEffect>("FreqMatrixEffect", "light/effects/effects.md#freqmatrix"); - mm::ModuleFactory::registerType<mm::FreqSawsEffect>("FreqSawsEffect", "light/effects/effects.md#freqsaws"); - mm::ModuleFactory::registerType<mm::GameOfLifeEffect>("GameOfLifeEffect", "light/effects/effects.md#gameoflife"); - mm::ModuleFactory::registerType<mm::GEQEffect>("GEQEffect", "light/effects/effects.md#geq"); - mm::ModuleFactory::registerType<mm::GEQ3DEffect>("GEQ3DEffect", "light/effects/effects.md#geq3d"); - mm::ModuleFactory::registerType<mm::LavaLampEffect>("LavaLampEffect", "light/effects/effects.md#lavalamp"); - mm::ModuleFactory::registerType<mm::LinesEffect>("LinesEffect", "light/effects/effects.md#lines"); - mm::ModuleFactory::registerType<mm::LissajousEffect>("LissajousEffect", "light/effects/effects.md#lissajous"); - mm::ModuleFactory::registerType<mm::MetaballsEffect>("MetaballsEffect", "light/effects/effects.md#metaballs"); - mm::ModuleFactory::registerType<mm::MoonLiveEffect>("MoonLiveEffect", "light/moonlive/MoonLiveEffect.md"); - mm::ModuleFactory::registerType<mm::NetworkReceiveEffect>("NetworkReceiveEffect", "light/effects/effects.md#networkreceive"); - mm::ModuleFactory::registerType<mm::NoiseEffect>("NoiseEffect", "light/effects/effects.md#noise"); - mm::ModuleFactory::registerType<mm::Noise2DEffect>("Noise2DEffect", "light/effects/effects.md#noise2d"); - mm::ModuleFactory::registerType<mm::NoiseMeterEffect>("NoiseMeterEffect", "light/effects/effects.md#noisemeter"); - mm::ModuleFactory::registerType<mm::PaintBrushEffect>("PaintBrushEffect", "light/effects/effects.md#paintbrush"); - mm::ModuleFactory::registerType<mm::ParticlesEffect>("ParticlesEffect", "light/effects/effects.md#particles"); - mm::ModuleFactory::registerType<mm::PlasmaEffect>("PlasmaEffect", "light/effects/effects.md#plasma"); - mm::ModuleFactory::registerType<mm::PraxisEffect>("PraxisEffect", "light/effects/effects.md#praxis"); - mm::ModuleFactory::registerType<mm::RainbowEffect>("RainbowEffect", "light/effects/effects.md#rainbow"); - mm::ModuleFactory::registerType<mm::RandomEffect>("RandomEffect", "light/effects/effects.md#random"); - mm::ModuleFactory::registerType<mm::RingsEffect>("RingsEffect", "light/effects/effects.md#rings"); - mm::ModuleFactory::registerType<mm::RipplesEffect>("RipplesEffect", "light/effects/effects.md#ripples"); - mm::ModuleFactory::registerType<mm::RubiksCubeEffect>("RubiksCubeEffect", "light/effects/effects.md#rubikscube"); - mm::ModuleFactory::registerType<mm::SineEffect>("SineEffect", "light/effects/effects.md#sine"); - mm::ModuleFactory::registerType<mm::SolidEffect>("SolidEffect", "light/effects/effects.md#solid"); - mm::ModuleFactory::registerType<mm::SphereMoveEffect>("SphereMoveEffect", "light/effects/effects.md#spheremove"); - mm::ModuleFactory::registerType<mm::SpiralEffect>("SpiralEffect", "light/effects/effects.md#spiral"); - mm::ModuleFactory::registerType<mm::StarFieldEffect>("StarFieldEffect", "light/effects/effects.md#starfield"); - mm::ModuleFactory::registerType<mm::StarSkyEffect>("StarSkyEffect", "light/effects/effects.md#starsky"); - mm::ModuleFactory::registerType<mm::TetrixEffect>("TetrixEffect", "light/effects/effects.md#tetrix"); - mm::ModuleFactory::registerType<mm::TextEffect>("TextEffect", "light/effects/effects.md#text"); - mm::ModuleFactory::registerType<mm::WaveEffect>("WaveEffect", "light/effects/effects.md#wave"); + mm::ModuleFactory::registerType<mm::AudioSpectrumEffect>("AudioSpectrumEffect", "light/effects.md#audiospectrum"); + mm::ModuleFactory::registerType<mm::AudioVolumeEffect>("AudioVolumeEffect", "light/effects.md#audiovolume"); + mm::ModuleFactory::registerType<mm::BlurzEffect>("BlurzEffect", "light/effects.md#blurz"); + mm::ModuleFactory::registerType<mm::BouncingBallsEffect>("BouncingBallsEffect", "light/effects.md#bouncingballs"); + mm::ModuleFactory::registerType<mm::DemoReelEffect>("DemoReelEffect", "light/effects.md#demoreel"); + mm::ModuleFactory::registerType<mm::DistortionWavesEffect>("DistortionWavesEffect", "light/effects.md#distortionwaves"); + mm::ModuleFactory::registerType<mm::FireEffect>("FireEffect", "light/effects.md#fire"); + mm::ModuleFactory::registerType<mm::FixedRectangleEffect>("FixedRectangleEffect", "light/effects.md#fixedrectangle"); + mm::ModuleFactory::registerType<mm::FreqMatrixEffect>("FreqMatrixEffect", "light/effects.md#freqmatrix"); + mm::ModuleFactory::registerType<mm::FreqSawsEffect>("FreqSawsEffect", "light/effects.md#freqsaws"); + mm::ModuleFactory::registerType<mm::GameOfLifeEffect>("GameOfLifeEffect", "light/effects.md#gameoflife"); + mm::ModuleFactory::registerType<mm::GEQEffect>("GEQEffect", "light/effects.md#geq"); + mm::ModuleFactory::registerType<mm::GEQ3DEffect>("GEQ3DEffect", "light/effects.md#geq3d"); + mm::ModuleFactory::registerType<mm::LavaLampEffect>("LavaLampEffect", "light/effects.md#lavalamp"); + mm::ModuleFactory::registerType<mm::LinesEffect>("LinesEffect", "light/effects.md#lines"); + mm::ModuleFactory::registerType<mm::LissajousEffect>("LissajousEffect", "light/effects.md#lissajous"); + mm::ModuleFactory::registerType<mm::MetaballsEffect>("MetaballsEffect", "light/effects.md#metaballs"); + mm::ModuleFactory::registerType<mm::MoonLiveEffect>("MoonLiveEffect", "light/MoonLiveEffect.md"); + mm::ModuleFactory::registerType<mm::NetworkReceiveEffect>("NetworkReceiveEffect", "light/effects.md#networkreceive"); + mm::ModuleFactory::registerType<mm::NoiseEffect>("NoiseEffect", "light/effects.md#noise"); + mm::ModuleFactory::registerType<mm::Noise2DEffect>("Noise2DEffect", "light/effects.md#noise2d"); + mm::ModuleFactory::registerType<mm::NoiseMeterEffect>("NoiseMeterEffect", "light/effects.md#noisemeter"); + mm::ModuleFactory::registerType<mm::PaintBrushEffect>("PaintBrushEffect", "light/effects.md#paintbrush"); + mm::ModuleFactory::registerType<mm::ParticlesEffect>("ParticlesEffect", "light/effects.md#particles"); + mm::ModuleFactory::registerType<mm::PlasmaEffect>("PlasmaEffect", "light/effects.md#plasma"); + mm::ModuleFactory::registerType<mm::PraxisEffect>("PraxisEffect", "light/effects.md#praxis"); + mm::ModuleFactory::registerType<mm::RainbowEffect>("RainbowEffect", "light/effects.md#rainbow"); + mm::ModuleFactory::registerType<mm::RandomEffect>("RandomEffect", "light/effects.md#random"); + mm::ModuleFactory::registerType<mm::RingsEffect>("RingsEffect", "light/effects.md#rings"); + mm::ModuleFactory::registerType<mm::RipplesEffect>("RipplesEffect", "light/effects.md#ripples"); + mm::ModuleFactory::registerType<mm::RubiksCubeEffect>("RubiksCubeEffect", "light/effects.md#rubikscube"); + mm::ModuleFactory::registerType<mm::SineEffect>("SineEffect", "light/effects.md#sine"); + mm::ModuleFactory::registerType<mm::SolidEffect>("SolidEffect", "light/effects.md#solid"); + mm::ModuleFactory::registerType<mm::SphereMoveEffect>("SphereMoveEffect", "light/effects.md#spheremove"); + mm::ModuleFactory::registerType<mm::SpiralEffect>("SpiralEffect", "light/effects.md#spiral"); + mm::ModuleFactory::registerType<mm::StarFieldEffect>("StarFieldEffect", "light/effects.md#starfield"); + mm::ModuleFactory::registerType<mm::StarSkyEffect>("StarSkyEffect", "light/effects.md#starsky"); + mm::ModuleFactory::registerType<mm::TetrixEffect>("TetrixEffect", "light/effects.md#tetrix"); + mm::ModuleFactory::registerType<mm::TextEffect>("TextEffect", "light/effects.md#text"); + mm::ModuleFactory::registerType<mm::WaveEffect>("WaveEffect", "light/effects.md#wave"); // Modifiers — alphabetical by display name. - mm::ModuleFactory::registerType<mm::BlockModifier>("BlockModifier", "light/modifiers/modifiers.md#block"); - mm::ModuleFactory::registerType<mm::CheckerboardModifier>("CheckerboardModifier", "light/modifiers/modifiers.md#checkerboard"); - mm::ModuleFactory::registerType<mm::CircleModifier>("CircleModifier", "light/modifiers/modifiers.md#circle"); - mm::ModuleFactory::registerType<mm::MirrorModifier>("MirrorModifier", "light/modifiers/modifiers.md#mirror"); - mm::ModuleFactory::registerType<mm::MultiplyModifier>("MultiplyModifier", "light/modifiers/modifiers.md#multiply"); - mm::ModuleFactory::registerType<mm::PinwheelModifier>("PinwheelModifier", "light/modifiers/modifiers.md#pinwheel"); - mm::ModuleFactory::registerType<mm::RandomMapModifier>("RandomMapModifier", "light/modifiers/modifiers.md#randommap"); - mm::ModuleFactory::registerType<mm::RegionModifier>("RegionModifier", "light/modifiers/modifiers.md#region"); - mm::ModuleFactory::registerType<mm::RippleXZModifier>("RippleXZModifier", "light/modifiers/modifiers.md#ripplexz"); - mm::ModuleFactory::registerType<mm::RotateModifier>("RotateModifier", "light/modifiers/modifiers.md#rotate"); - mm::ModuleFactory::registerType<mm::TransposeModifier>("TransposeModifier", "light/modifiers/modifiers.md#transpose"); - mm::ModuleFactory::registerType<mm::HueDriver>("HueDriver", "light/drivers/HueDriver.md"); - mm::ModuleFactory::registerType<mm::NetworkSendDriver>("NetworkSendDriver", "light/drivers/NetworkSendDriver.md"); - mm::ModuleFactory::registerType<mm::PreviewDriver>("PreviewDriver", "light/drivers/PreviewDriver.md"); + mm::ModuleFactory::registerType<mm::BlockModifier>("BlockModifier", "light/modifiers.md#block"); + mm::ModuleFactory::registerType<mm::CheckerboardModifier>("CheckerboardModifier", "light/modifiers.md#checkerboard"); + mm::ModuleFactory::registerType<mm::CircleModifier>("CircleModifier", "light/modifiers.md#circle"); + mm::ModuleFactory::registerType<mm::MirrorModifier>("MirrorModifier", "light/modifiers.md#mirror"); + mm::ModuleFactory::registerType<mm::MultiplyModifier>("MultiplyModifier", "light/modifiers.md#multiply"); + mm::ModuleFactory::registerType<mm::PinwheelModifier>("PinwheelModifier", "light/modifiers.md#pinwheel"); + mm::ModuleFactory::registerType<mm::RandomMapModifier>("RandomMapModifier", "light/modifiers.md#randommap"); + mm::ModuleFactory::registerType<mm::RegionModifier>("RegionModifier", "light/modifiers.md#region"); + mm::ModuleFactory::registerType<mm::RippleXZModifier>("RippleXZModifier", "light/modifiers.md#ripplexz"); + mm::ModuleFactory::registerType<mm::RotateModifier>("RotateModifier", "light/modifiers.md#rotate"); + mm::ModuleFactory::registerType<mm::TransposeModifier>("TransposeModifier", "light/modifiers.md#transpose"); + mm::ModuleFactory::registerType<mm::HueDriver>("HueDriver", "light/drivers.md#hue"); + mm::ModuleFactory::registerType<mm::NetworkSendDriver>("NetworkSendDriver", "light/drivers.md#networksend"); + mm::ModuleFactory::registerType<mm::PreviewDriver>("PreviewDriver", "light/drivers.md#preview"); // Register only the LED drivers this chip's silicon can run (see the gated // includes above) — keeps the type picker honest (no LcdLedDriver offered on a // chip without LCD_CAM) and the binary lean. #if defined(CONFIG_SOC_RMT_SUPPORTED) - mm::ModuleFactory::registerType<mm::RmtLedDriver>("RmtLedDriver", "light/drivers/RmtLedDriver.md"); + mm::ModuleFactory::registerType<mm::RmtLedDriver>("RmtLedDriver", "light/drivers.md#rmtled"); #endif #if defined(CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED) - mm::ModuleFactory::registerType<mm::LcdLedDriver>("LcdLedDriver", "light/drivers/LcdLedDriver.md"); + mm::ModuleFactory::registerType<mm::LcdLedDriver>("LcdLedDriver", "light/drivers.md#lcdled"); #endif #if defined(CONFIG_SOC_PARLIO_SUPPORTED) - mm::ModuleFactory::registerType<mm::ParlioLedDriver>("ParlioLedDriver", "light/drivers/ParlioLedDriver.md"); + mm::ModuleFactory::registerType<mm::ParlioLedDriver>("ParlioLedDriver", "light/drivers.md#parlioled"); #endif - mm::ModuleFactory::registerType<mm::HttpServerModule>("HttpServerModule", "core/ui/ui.md"); - mm::ModuleFactory::registerType<mm::SystemModule>("SystemModule", "core/ui/ui.md#system"); - mm::ModuleFactory::registerType<mm::AudioModule>("AudioModule", "core/ui/ui.md#audio"); - mm::ModuleFactory::registerType<mm::I2cScanModule>("I2cScanModule", "core/ui/ui.md#i2c-scan"); - mm::ModuleFactory::registerType<mm::IrModule>("IrModule", "core/ui/ui.md#ir"); - mm::ModuleFactory::registerType<mm::FileManagerModule>("FileManagerModule", "core/ui/ui.md#file-manager"); - mm::ModuleFactory::registerType<mm::FirmwareUpdateModule>("FirmwareUpdateModule", "core/ui/ui.md#firmware-update"); - mm::ModuleFactory::registerType<mm::ImprovProvisioningModule>("ImprovProvisioningModule", "core/ui/ui.md#improv-provisioning"); - mm::ModuleFactory::registerType<mm::MqttModule>("MqttModule", "core/ui/ui.md#mqtt"); - mm::ModuleFactory::registerType<mm::DevicesModule>("DevicesModule", "core/ui/ui.md#devices"); - mm::ModuleFactory::registerType<mm::NetworkModule>("NetworkModule", "core/ui/ui.md#network"); - mm::ModuleFactory::registerType<mm::FilesystemModule>("FilesystemModule", "core/ui/ui.md#filesystem"); + mm::ModuleFactory::registerType<mm::HttpServerModule>("HttpServerModule", "core/services.md"); + mm::ModuleFactory::registerType<mm::SystemModule>("SystemModule", "core/services.md#system"); + mm::ModuleFactory::registerType<mm::AudioModule>("AudioModule", "core/services.md#audio"); + mm::ModuleFactory::registerType<mm::I2cScanModule>("I2cScanModule", "core/services.md#i2c-scan"); + mm::ModuleFactory::registerType<mm::IrModule>("IrModule", "core/services.md#ir"); + mm::ModuleFactory::registerType<mm::FileManagerModule>("FileManagerModule", "core/services.md#file-manager"); + mm::ModuleFactory::registerType<mm::FirmwareUpdateModule>("FirmwareUpdateModule", "core/services.md#firmware-update"); + mm::ModuleFactory::registerType<mm::ImprovProvisioningModule>("ImprovProvisioningModule", "core/services.md#improv-provisioning"); + mm::ModuleFactory::registerType<mm::MqttModule>("MqttModule", "core/services.md#mqtt"); + mm::ModuleFactory::registerType<mm::DevicesModule>("DevicesModule", "core/services.md#devices"); + mm::ModuleFactory::registerType<mm::NetworkModule>("NetworkModule", "core/services.md#network"); + mm::ModuleFactory::registerType<mm::FilesystemModule>("FilesystemModule", "core/supporting.md#filesystem"); } static void printModuleMetrics(mm::MoonModule* mod, int depth) { diff --git a/src/ui/app.js b/src/ui/app.js index 8fd579e4..4cdf5176 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -520,7 +520,7 @@ function createCard(mod, depth) { // Help link → the module's spec page on the rendered docs site, far right of // the row. docPath comes from /api/types (relative to docs/moonmodules/, e.g. - // "core/ui/ui.md#audio" or "light/effects/effects.md#fire"); omitted if none. + // "core/services.md#audio" or "light/effects.md#fire"); omitted if none. // The site is Material for MkDocs at moonmodules.org/projectMM/ (flat URLs, so // foo.md → foo.html; the MkDocs heading slugs match these #anchors), reached // via the same /projectMM/ subpath the installer uses. Convert only the `.md` diff --git a/test/unit/light/unit_BlurzEffect.cpp b/test/unit/light/unit_BlurzEffect.cpp index ffb0f437..51877205 100644 --- a/test/unit/light/unit_BlurzEffect.cpp +++ b/test/unit/light/unit_BlurzEffect.cpp @@ -2,6 +2,7 @@ // @also AudioModule #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/BlurzEffect.h" #include "light/layouts/GridLayout.h" #include "core/AudioModule.h" diff --git a/test/unit/light/unit_BouncingBallsEffect.cpp b/test/unit/light/unit_BouncingBallsEffect.cpp index 07c89cdc..a4fdf22e 100644 --- a/test/unit/light/unit_BouncingBallsEffect.cpp +++ b/test/unit/light/unit_BouncingBallsEffect.cpp @@ -1,6 +1,7 @@ // @module BouncingBallsEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/BouncingBallsEffect.h" #include "light/layouts/GridLayout.h" #include "platform/platform.h" // setTestNowMs — freeze millis() for a deterministic first frame diff --git a/test/unit/light/unit_DistortionWavesEffect.cpp b/test/unit/light/unit_DistortionWavesEffect.cpp index 2b618b48..865cf692 100644 --- a/test/unit/light/unit_DistortionWavesEffect.cpp +++ b/test/unit/light/unit_DistortionWavesEffect.cpp @@ -1,6 +1,7 @@ // @module DistortionWavesEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/DistortionWavesEffect.h" #include "light/layouts/GridLayout.h" diff --git a/test/unit/light/unit_FireEffect.cpp b/test/unit/light/unit_FireEffect.cpp index 3eaff016..9c2549f0 100644 --- a/test/unit/light/unit_FireEffect.cpp +++ b/test/unit/light/unit_FireEffect.cpp @@ -1,6 +1,7 @@ // @module FireEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/FireEffect.h" #include "light/layouts/GridLayout.h" diff --git a/test/unit/light/unit_FixedRectangleEffect.cpp b/test/unit/light/unit_FixedRectangleEffect.cpp index 05171467..2c912a03 100644 --- a/test/unit/light/unit_FixedRectangleEffect.cpp +++ b/test/unit/light/unit_FixedRectangleEffect.cpp @@ -1,6 +1,7 @@ // @module FixedRectangleEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/FixedRectangleEffect.h" #include "light/layouts/GridLayout.h" diff --git a/test/unit/light/unit_FreqMatrixEffect.cpp b/test/unit/light/unit_FreqMatrixEffect.cpp index 4dfbe4af..533efdbe 100644 --- a/test/unit/light/unit_FreqMatrixEffect.cpp +++ b/test/unit/light/unit_FreqMatrixEffect.cpp @@ -2,6 +2,7 @@ // @also AudioModule #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/FreqMatrixEffect.h" #include "light/layouts/GridLayout.h" #include "core/AudioModule.h" diff --git a/test/unit/light/unit_FreqSawsEffect.cpp b/test/unit/light/unit_FreqSawsEffect.cpp index e491d909..28515e70 100644 --- a/test/unit/light/unit_FreqSawsEffect.cpp +++ b/test/unit/light/unit_FreqSawsEffect.cpp @@ -2,6 +2,7 @@ // @also AudioModule #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/FreqSawsEffect.h" #include "light/layouts/GridLayout.h" #include "core/AudioModule.h" diff --git a/test/unit/light/unit_GEQ3DEffect.cpp b/test/unit/light/unit_GEQ3DEffect.cpp index e6b5b87a..83167d35 100644 --- a/test/unit/light/unit_GEQ3DEffect.cpp +++ b/test/unit/light/unit_GEQ3DEffect.cpp @@ -2,6 +2,7 @@ // @also AudioModule #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/GEQ3DEffect.h" #include "light/layouts/GridLayout.h" #include "core/AudioModule.h" diff --git a/test/unit/light/unit_GEQEffect.cpp b/test/unit/light/unit_GEQEffect.cpp index 842732c0..b5004dbd 100644 --- a/test/unit/light/unit_GEQEffect.cpp +++ b/test/unit/light/unit_GEQEffect.cpp @@ -2,6 +2,7 @@ // @also AudioModule #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/GEQEffect.h" #include "light/layouts/GridLayout.h" #include "core/AudioModule.h" diff --git a/test/unit/light/unit_GridLayout.cpp b/test/unit/light/unit_GridLayout.cpp index 243ad50b..c21ea438 100644 --- a/test/unit/light/unit_GridLayout.cpp +++ b/test/unit/light/unit_GridLayout.cpp @@ -2,6 +2,7 @@ // @also Layouts #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/layouts/GridLayout.h" #include <vector> diff --git a/test/unit/light/unit_Layer_extrude.cpp b/test/unit/light/unit_Layer_extrude.cpp index 7f45c269..77075dfd 100644 --- a/test/unit/light/unit_Layer_extrude.cpp +++ b/test/unit/light/unit_Layer_extrude.cpp @@ -2,6 +2,7 @@ // @also RainbowEffect, NoiseEffect, PlasmaEffect, SpiralEffect, FireEffect, ParticlesEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/layers/Layer.h" #include "light/layouts/GridLayout.h" #include "light/effects/RainbowEffect.h" diff --git a/test/unit/light/unit_Layers_container.cpp b/test/unit/light/unit_Layers_container.cpp index 06e4de0a..1331c3c5 100644 --- a/test/unit/light/unit_Layers_container.cpp +++ b/test/unit/light/unit_Layers_container.cpp @@ -2,6 +2,7 @@ // @also Layer #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/layers/Layers.h" #include "light/layouts/GridLayout.h" #include "light/effects/RainbowEffect.h" diff --git a/test/unit/light/unit_LissajousEffect.cpp b/test/unit/light/unit_LissajousEffect.cpp index 426acf4f..21896347 100644 --- a/test/unit/light/unit_LissajousEffect.cpp +++ b/test/unit/light/unit_LissajousEffect.cpp @@ -1,6 +1,7 @@ // @module LissajousEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/LissajousEffect.h" #include "light/layouts/GridLayout.h" diff --git a/test/unit/light/unit_MetaballsEffect.cpp b/test/unit/light/unit_MetaballsEffect.cpp index a2db3577..1aa0df1e 100644 --- a/test/unit/light/unit_MetaballsEffect.cpp +++ b/test/unit/light/unit_MetaballsEffect.cpp @@ -1,6 +1,7 @@ // @module MetaballsEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/MetaballsEffect.h" #include "light/layouts/GridLayout.h" diff --git a/test/unit/light/unit_NetworkReceiveEffect.cpp b/test/unit/light/unit_NetworkReceiveEffect.cpp index 4fd85109..52ab2977 100644 --- a/test/unit/light/unit_NetworkReceiveEffect.cpp +++ b/test/unit/light/unit_NetworkReceiveEffect.cpp @@ -2,6 +2,7 @@ // @also NetworkSendDriver #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/ArtNetPacket.h" #include "light/effects/NetworkReceiveEffect.h" #include "light/layouts/GridLayout.h" diff --git a/test/unit/light/unit_NetworkReceiveEffect_protocols.cpp b/test/unit/light/unit_NetworkReceiveEffect_protocols.cpp index 84362993..79ca02aa 100644 --- a/test/unit/light/unit_NetworkReceiveEffect_protocols.cpp +++ b/test/unit/light/unit_NetworkReceiveEffect_protocols.cpp @@ -2,6 +2,7 @@ // @also NetworkSendDriver #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/ArtNetPacket.h" #include "light/DdpPacket.h" #include "light/E131Packet.h" diff --git a/test/unit/light/unit_Noise2DEffect.cpp b/test/unit/light/unit_Noise2DEffect.cpp index 1dfaf085..212192c5 100644 --- a/test/unit/light/unit_Noise2DEffect.cpp +++ b/test/unit/light/unit_Noise2DEffect.cpp @@ -1,6 +1,7 @@ // @module Noise2DEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/Noise2DEffect.h" #include "light/layouts/GridLayout.h" diff --git a/test/unit/light/unit_NoiseEffect.cpp b/test/unit/light/unit_NoiseEffect.cpp index 6223b73b..5fb063e4 100644 --- a/test/unit/light/unit_NoiseEffect.cpp +++ b/test/unit/light/unit_NoiseEffect.cpp @@ -2,6 +2,7 @@ // @also PlasmaEffect, RainbowEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/NoiseEffect.h" #include "light/effects/PlasmaEffect.h" #include "light/effects/RainbowEffect.h" diff --git a/test/unit/light/unit_NoiseMeterEffect.cpp b/test/unit/light/unit_NoiseMeterEffect.cpp index c1483c93..d2286d59 100644 --- a/test/unit/light/unit_NoiseMeterEffect.cpp +++ b/test/unit/light/unit_NoiseMeterEffect.cpp @@ -2,6 +2,7 @@ // @also AudioModule #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/NoiseMeterEffect.h" #include "light/layouts/GridLayout.h" #include "core/AudioModule.h" diff --git a/test/unit/light/unit_PaintBrushEffect.cpp b/test/unit/light/unit_PaintBrushEffect.cpp index 4b883cf9..ee08d15b 100644 --- a/test/unit/light/unit_PaintBrushEffect.cpp +++ b/test/unit/light/unit_PaintBrushEffect.cpp @@ -2,6 +2,7 @@ // @also AudioModule #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/PaintBrushEffect.h" #include "light/layouts/GridLayout.h" #include "core/AudioModule.h" diff --git a/test/unit/light/unit_ParticlesEffect.cpp b/test/unit/light/unit_ParticlesEffect.cpp index b368d8c6..61c3bab0 100644 --- a/test/unit/light/unit_ParticlesEffect.cpp +++ b/test/unit/light/unit_ParticlesEffect.cpp @@ -1,6 +1,7 @@ // @module ParticlesEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/ParticlesEffect.h" #include "light/layouts/GridLayout.h" diff --git a/test/unit/light/unit_PlasmaEffect.cpp b/test/unit/light/unit_PlasmaEffect.cpp index 2bae1217..9c98691d 100644 --- a/test/unit/light/unit_PlasmaEffect.cpp +++ b/test/unit/light/unit_PlasmaEffect.cpp @@ -2,6 +2,7 @@ // @also NoiseEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/PlasmaEffect.h" #include "light/effects/NoiseEffect.h" #include "light/layouts/GridLayout.h" diff --git a/test/unit/light/unit_PraxisEffect.cpp b/test/unit/light/unit_PraxisEffect.cpp index d2d4d9fe..8484c853 100644 --- a/test/unit/light/unit_PraxisEffect.cpp +++ b/test/unit/light/unit_PraxisEffect.cpp @@ -1,6 +1,7 @@ // @module PraxisEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/PraxisEffect.h" #include "light/layouts/GridLayout.h" diff --git a/test/unit/light/unit_RainbowEffect.cpp b/test/unit/light/unit_RainbowEffect.cpp index ecad8e78..52e1d690 100644 --- a/test/unit/light/unit_RainbowEffect.cpp +++ b/test/unit/light/unit_RainbowEffect.cpp @@ -1,6 +1,7 @@ // @module RainbowEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/RainbowEffect.h" #include "light/layouts/GridLayout.h" diff --git a/test/unit/light/unit_RandomEffect.cpp b/test/unit/light/unit_RandomEffect.cpp index a26f8482..43f9b69a 100644 --- a/test/unit/light/unit_RandomEffect.cpp +++ b/test/unit/light/unit_RandomEffect.cpp @@ -1,6 +1,7 @@ // @module RandomEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/RandomEffect.h" #include "light/layouts/GridLayout.h" diff --git a/test/unit/light/unit_RubiksCubeEffect.cpp b/test/unit/light/unit_RubiksCubeEffect.cpp index e759af37..ac8c564c 100644 --- a/test/unit/light/unit_RubiksCubeEffect.cpp +++ b/test/unit/light/unit_RubiksCubeEffect.cpp @@ -1,6 +1,7 @@ // @module RubiksCubeEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/RubiksCubeEffect.h" #include "light/layouts/GridLayout.h" #include "platform/platform.h" // setTestNowMs — freeze millis() so the first frame runs init() deterministically diff --git a/test/unit/light/unit_SineEffect.cpp b/test/unit/light/unit_SineEffect.cpp index 49eb9de5..48375fcc 100644 --- a/test/unit/light/unit_SineEffect.cpp +++ b/test/unit/light/unit_SineEffect.cpp @@ -1,6 +1,7 @@ // @module SineEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/SineEffect.h" #include "light/layouts/GridLayout.h" diff --git a/test/unit/light/unit_SolidEffect.cpp b/test/unit/light/unit_SolidEffect.cpp index 7816c983..889c469e 100644 --- a/test/unit/light/unit_SolidEffect.cpp +++ b/test/unit/light/unit_SolidEffect.cpp @@ -1,6 +1,7 @@ // @module SolidEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/SolidEffect.h" #include "light/layouts/GridLayout.h" diff --git a/test/unit/light/unit_SphereMoveEffect.cpp b/test/unit/light/unit_SphereMoveEffect.cpp index fa6fde03..2129ccd6 100644 --- a/test/unit/light/unit_SphereMoveEffect.cpp +++ b/test/unit/light/unit_SphereMoveEffect.cpp @@ -1,6 +1,7 @@ // @module SphereMoveEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/SphereMoveEffect.h" #include "light/layouts/GridLayout.h" diff --git a/test/unit/light/unit_StarFieldEffect.cpp b/test/unit/light/unit_StarFieldEffect.cpp index 4861a44f..10f4da03 100644 --- a/test/unit/light/unit_StarFieldEffect.cpp +++ b/test/unit/light/unit_StarFieldEffect.cpp @@ -1,6 +1,7 @@ // @module StarFieldEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/StarFieldEffect.h" #include "light/layouts/GridLayout.h" #include "platform/platform.h" // setTestNowMs — drive the throttle past its interval deterministically diff --git a/test/unit/light/unit_StarSkyEffect.cpp b/test/unit/light/unit_StarSkyEffect.cpp index 5f5530a9..900661d1 100644 --- a/test/unit/light/unit_StarSkyEffect.cpp +++ b/test/unit/light/unit_StarSkyEffect.cpp @@ -1,6 +1,7 @@ // @module StarSkyEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/StarSkyEffect.h" #include "light/layouts/GridLayout.h" diff --git a/test/unit/light/unit_TetrixEffect.cpp b/test/unit/light/unit_TetrixEffect.cpp index e96c377a..d84df020 100644 --- a/test/unit/light/unit_TetrixEffect.cpp +++ b/test/unit/light/unit_TetrixEffect.cpp @@ -1,6 +1,7 @@ // @module TetrixEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/TetrixEffect.h" #include "light/layouts/GridLayout.h" #include "platform/platform.h" // setTestNowMs — deterministic virtual time diff --git a/test/unit/light/unit_effects_render.cpp b/test/unit/light/unit_effects_render.cpp index 5f82374c..ace12a94 100644 --- a/test/unit/light/unit_effects_render.cpp +++ b/test/unit/light/unit_effects_render.cpp @@ -2,6 +2,7 @@ // @also RingsEffect, RipplesEffect, LavaLampEffect #include "doctest.h" +#include "light/layouts/Layouts.h" #include "light/effects/SpiralEffect.h" #include "light/effects/RingsEffect.h" #include "light/effects/RipplesEffect.h" diff --git a/web-installer/README.md b/web-installer/README.md index d3d5aa4b..628aacd4 100644 --- a/web-installer/README.md +++ b/web-installer/README.md @@ -54,7 +54,7 @@ its `supported`/`planned` capability chips. It reuses the installer's flash mach shared [`install-picker.js`](../../src/ui/install-picker.js) through a hidden board `<select>`, so the firmware-narrowing and flash flow are identical to a plain dropdown pick. Board images are a Pages-only asset (staged from -`docs/assets/boards/`), never flashed. +`docs/assets/deviceModels/`), never flashed. The picker is a collapsed row consistent with the other fields; clicking it expands the searchable card grid, and picking a board collapses it back to a @@ -115,7 +115,7 @@ can't supply); every other driver — `RmtLedDriver`, `LcdLedDriver`, block (to the `Drivers` container), so a device only carries the outputs its board actually has instead of every driver the chip is capable of. The default LED driver per chip: **classic ESP32 → `RmtLedDriver`**, **S3 → `RmtLedDriver`** (LCD -needs the full 8-lane bus — see the [LcdLedDriver spec](../moonmodules/light/drivers/LcdLedDriver.md); +needs the full 8-lane bus — see the [LcdLedDriver spec](../moonmodules/light/moxygen/LcdLedDriver.md); 1..8-pin LCD is a future extension), **P4 → `ParlioLedDriver`** (runs 1–8 lanes). The `RmtLed` `controls` block presets the loopback self-test pins so a bench @@ -136,7 +136,7 @@ declares only what is actually on that board. | `name` | yes | identifier **and** display label (no key/label split) | | `chip` | yes | the MCU family, for the picker's chip filter | | `firmwares` | yes | the firmware variants flashable on this hardware; **the first entry is the default** the picker pre-selects (reorder the array to change the default) | -| `image` | no | board photo for the picker — a local path under `assets/boards/`, named for the board's slug (e.g. `assets/boards/quinled-dig-2-go.jpg`). Host our own copy, never a vendor hotlink — see [§ Board images & links](#board-images--links) below | +| `image` | no | board photo for the picker — a local path under `assets/deviceModels/`, named for the board's slug (e.g. `assets/deviceModels/quinled-dig-2-go.jpg`). Host our own copy, never a vendor hotlink — see [§ Board images & links](#board-images--links) below | | `url` | no | product-page link the picker shows next to the board (the vendor's own page, e.g. `https://quinled.info/quinled-dig2go/`). A remote URL is fine here — it's a click-through link, not an asset the installer fetches | | `supported` | no | short capability labels the firmware drives on this board *today* (e.g. `["LEDs", "WiFi", "Ethernet"]`), grounded in the modules the entry actually adds. Rendered as solid chips on the picker card | | `planned` | no | short labels for peripherals the board physically has but no module drives *yet* (e.g. `["IR receiver", "Onboard button"]`) — the backlog seed for future spec + test work. Rendered as dashed "(soon)" chips on the picker card | @@ -209,13 +209,13 @@ the picker's visual layer **and** the inputs to the per-board capability loop ab *derived* image, not the raw shot; (3) third-party product photos are the vendor's copyright — redistributing them needs permission. So: use the `url` to *find* the reference photo, then either shoot/redraw our own **or** check a local copy into - [`docs/assets/boards/`](../assets/boards/) **with permission**, named for the + [`docs/assets/deviceModels/`](../assets/deviceModels/) **with permission**, named for the board's slug. The catalog stores a local path, never a remote image URL. - **`url` — a remote link is fine.** It's a click-through to the vendor's own page, not an asset the installer fetches. -- **Deploy** stages only the *referenced* images (not the whole `docs/assets/boards/` +- **Deploy** stages only the *referenced* images (not the whole `docs/assets/deviceModels/` library, which also holds photos for boards not yet in the catalog) into - `install/assets/boards/`, so an `image: "assets/boards/<slug>.jpg"` resolves + `install/assets/deviceModels/`, so an `image: "assets/deviceModels/<slug>.jpg"` resolves same-origin from `/install/deviceModels.json`. **One entry type, no Board/Device split.** A "Device" (a finished rig like the diff --git a/web-installer/deviceModels.json b/web-installer/deviceModels.json index b44de44c..8af9676e 100644 --- a/web-installer/deviceModels.json +++ b/web-installer/deviceModels.json @@ -6,7 +6,7 @@ "esp32", "esp32-eth" ], - "image": "assets/boards/olimex-esp32-gateway-rev-g.jpg", + "image": "assets/deviceModels/olimex-esp32-gateway-rev-g.jpg", "url": "https://www.olimex.com/Products/IoT/ESP32/ESP32-GATEWAY/open-source-hardware", "supported": [ "LEDs", @@ -50,7 +50,7 @@ "esp32" ], "flashBaud": 460800, - "image": "assets/boards/lolin-d32.jpg", + "image": "assets/deviceModels/lolin-d32.jpg", "url": "https://www.wemos.cc/en/latest/d32/d32.html", "supported": [ "LEDs", @@ -80,7 +80,7 @@ "firmwares": [ "esp32" ], - "image": "assets/boards/generic-esp32-dev.jpg", + "image": "assets/deviceModels/generic-esp32-dev.jpg", "supported": [ "LEDs", "WiFi" @@ -107,7 +107,7 @@ "firmwares": [ "esp32" ], - "image": "assets/boards/quinled-dig-2-go.jpg", + "image": "assets/deviceModels/quinled-dig-2-go.jpg", "url": "https://quinled.info/quinled-dig2go/", "supported": [ "LEDs", @@ -143,7 +143,7 @@ "firmwares": [ "esp32" ], - "image": "assets/boards/quinled-dig-next-2.jpg", + "image": "assets/deviceModels/quinled-dig-next-2.jpg", "url": "https://quinled.info/dig-next-2/", "supported": [ "LEDs", @@ -178,7 +178,7 @@ "firmwares": [ "esp32" ], - "image": "assets/boards/quinled-dig-uno-v3.jpg", + "image": "assets/deviceModels/quinled-dig-uno-v3.jpg", "url": "https://quinled.info/pre-assembled-quinled-dig-uno/", "supported": [ "LEDs", @@ -212,7 +212,7 @@ "firmwares": [ "esp32" ], - "image": "assets/boards/quinled-dig-quad-v3.jpg", + "image": "assets/deviceModels/quinled-dig-quad-v3.jpg", "url": "https://quinled.info/pre-assembled-quinled-dig-quad/", "supported": [ "LEDs", @@ -247,7 +247,7 @@ "firmwares": [ "esp32-16mb" ], - "image": "assets/boards/quinled-dig-octa-32-8l.jpg", + "image": "assets/deviceModels/quinled-dig-octa-32-8l.jpg", "url": "https://quinled.info/quinled-dig-octa-brainboard-32-8l/", "supported": [ "LEDs", @@ -297,7 +297,7 @@ "firmwares": [ "esp32-16mb" ], - "image": "assets/boards/serg-unishield-v5.jpg", + "image": "assets/deviceModels/serg-unishield-v5.jpg", "url": "https://www.tindie.com/stores/serg74/", "supported": [ "LEDs", @@ -333,7 +333,7 @@ "firmwares": [ "esp32-16mb" ], - "image": "assets/boards/serg-minishield.jpg", + "image": "assets/deviceModels/serg-minishield.jpg", "url": "https://www.tindie.com/stores/serg74/", "supported": [ "LEDs", @@ -397,7 +397,7 @@ "firmwares": [ "esp32" ], - "image": "assets/boards/mhc-v57-pro.jpg", + "image": "assets/deviceModels/mhc-v57-pro.jpg", "url": "https://shop.myhome-control.de/en/ABC-WLED-controller-PRO-V57-with-iMOSFET/HW10030", "supported": [ "LEDs", @@ -488,7 +488,7 @@ "firmwares": [ "esp32s3-n16r8" ], - "image": "assets/boards/esp32-s3-n16r8-dev.jpg", + "image": "assets/deviceModels/esp32-s3-n16r8-dev.jpg", "supported": [ "LEDs", "WiFi" @@ -524,7 +524,7 @@ "firmwares": [ "esp32s3-n16r8" ], - "image": "assets/boards/esp32-s3-n16r8-dev.jpg", + "image": "assets/deviceModels/esp32-s3-n16r8-dev.jpg", "supported": [ "LEDs", "WiFi", @@ -616,7 +616,7 @@ "firmwares": [ "esp32-16mb" ], - "image": "assets/boards/esp32-d0-16mb.jpg", + "image": "assets/deviceModels/esp32-d0-16mb.jpg", "supported": [ "LEDs", "WiFi" @@ -649,7 +649,7 @@ "firmwares": [ "esp32p4-eth" ], - "image": "assets/boards/waveshare-esp32-p4-nano.jpg", + "image": "assets/deviceModels/waveshare-esp32-p4-nano.jpg", "supported": [ "LEDs", "Ethernet" @@ -725,7 +725,7 @@ "firmwares": [ "esp32s3-n8r8" ], - "image": "assets/boards/lightcrafter-16.jpg", + "image": "assets/deviceModels/lightcrafter-16.jpg", "url": "https://www.limpkin.fr/", "supported": [ "LEDs", @@ -779,7 +779,7 @@ "firmwares": [ "esp32s3-n8r8" ], - "image": "assets/boards/esp32-s3-stephanelec-16p.jpg", + "image": "assets/deviceModels/esp32-s3-stephanelec-16p.jpg", "url": "https://www.limpkin.fr/", "supported": [ "LEDs", @@ -833,7 +833,7 @@ "esp32p4-eth", "esp32p4-eth-wifi" ], - "image": "assets/boards/waveshare-esp32-p4-nano.jpg", + "image": "assets/deviceModels/waveshare-esp32-p4-nano.jpg", "url": "https://www.waveshare.com/esp32-p4-nano.htm", "supported": [ "LEDs", @@ -891,7 +891,7 @@ "firmwares": [ "esp32p4-eth" ], - "image": "assets/boards/mhc-wled-esp32-p4-shield.jpg", + "image": "assets/deviceModels/mhc-wled-esp32-p4-shield.jpg", "url": "https://shop.myhome-control.de/en/ABC-WLED-ESP32-P4-shield/HW10027", "supported": [ "LEDs", @@ -962,7 +962,7 @@ "firmwares": [ "esp32s31" ], - "image": "assets/boards/esp32-s31-function-coreboard-1.jpg", + "image": "assets/deviceModels/esp32-s31-function-coreboard-1.jpg", "url": "https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32s31/esp32-s31-function-coreboard-1/user_guide.html", "supported": [ "LEDs", @@ -1033,7 +1033,7 @@ "esp32" ], "flashBaud": 460800, - "image": "assets/boards/shelly.jpg", + "image": "assets/deviceModels/shelly.jpg", "supported": [ "LEDs", "WiFi", @@ -1064,8 +1064,8 @@ "parent_id": "Layers" }, { - "type": "SolidEffect", - "id": "Solid", + "type": "RainbowEffect", + "id": "Rainbow", "parent_id": "Layer" }, { diff --git a/web-installer/install.js b/web-installer/install.js index b92385f5..2b6d833e 100644 --- a/web-installer/install.js +++ b/web-installer/install.js @@ -1157,7 +1157,7 @@ document.addEventListener('DOMContentLoaded', () => { thumb.className = "bg-thumb" + (b.image ? "" : " noimg"); // the deploy stages a copy of deviceModels.json // + the referenced board images alongside this page, so an "image" path of - // "assets/boards/<slug>.jpg" resolves same-origin from this page. + // "assets/deviceModels/<slug>.jpg" resolves same-origin from this page. if (b.image) thumb.style.backgroundImage = `url("${b.image}")`; el.appendChild(thumb); const body = document.createElement("div"); body.className = "bg-body";