diff --git a/.claude/settings.local.cleaned.json b/.claude/settings.local.cleaned.json new file mode 100644 index 00000000..2b782704 --- /dev/null +++ b/.claude/settings.local.cleaned.json @@ -0,0 +1,83 @@ +{ + "permissions": { + "allow": [ + "Bash(./build/projectMM:*)", + "Bash(./build/test/mm_scenarios:*)", + "Bash(./build/test/mm_tests:*)", + "Bash(./test/mm_tests --test-case=\"AudioLevel*,AudioBands*\")", + "Bash(./test/mm_tests)", + "Bash(/usr/bin/curl -s --max-time 12 \"https://moonmodules.org/projectMM/install/boards.json\")", + "Bash(arp -a)", + "Bash(awk:*)", + "Bash(cat:*)", + "Bash(cmake --build /Users/ewoud/Developer/GitHub/MoonModules/projectMM/build -j 8)", + "Bash(cmake --build build --target mm_tests)", + "Bash(cmake --build build -j 8)", + "Bash(cmake --build build -j)", + "Bash(cmake --build build)", + "Bash(cmake -B build)", + "Bash(cp .claude/settings.local.json .claude/settings.local.cleaned.json)", + "Bash(ctest --output-on-failure)", + "Bash(ctest --test-dir build:*)", + "Bash(ctest)", + "Bash(curl -s --max-time 10 https://moonmodules.org/projectMM/install/)", + "Bash(curl -s --max-time 3 -o /dev/null -w \"%{http_code}\" http://localhost:8765/projectMM.bin)", + "Bash(curl -s -o /dev/null -w \" boards.json: HTTP %{http_code}\\\\n\" --max-time 10 https://moonmodules.org/projectMM/install/boards.json)", + "Bash(curl -s -o /dev/null -w \" index.html: HTTP %{http_code}\\\\n\" --max-time 10 https://moonmodules.org/projectMM/install/)", + "Bash(curl -sS *)", + "Bash(curl -sf *)", + "Bash(echo:*)", + "Bash(gh api *)", + "Bash(gh auth *)", + "Bash(gh pr *)", + "Bash(gh release *)", + "Bash(gh run *)", + "Bash(gh search *)", + "Bash(gh workflow *)", + "Bash(git -C ~/esp/esp-idf describe)", + "Bash(git check-ignore *)", + "Bash(git fetch *)", + "Bash(git mv:*)", + "Bash(grep:*)", + "Bash(gunzip:*)", + "Bash(head:*)", + "Bash(ipconfig getsummary *)", + "Bash(ls:*)", + "Bash(networksetup -listallhardwareports)", + "Bash(node --check:*)", + "Bash(pkill:*)", + "Bash(sed:*)", + "Bash(tail:*)", + "Bash(timeout 5 dns-sd -B _http._tcp local.)", + "Bash(uv run *)", + "Bash(xargs -n1 basename)", + "Read(//Users/ewoud/**)", + "Read(//Users/ewoud/.cache/**)", + "Read(//Users/ewoud/.espressif/tools/esp-rom-elfs/**)", + "Read(//Users/ewoud/Developer/GitHub/MoonModules/**)", + "Read(//Users/ewoud/Library/Caches/ms-playwright/**)", + "Read(//Users/ewoud/esp/esp-idf/**)", + "Read(//Users/ewoud/esp/esp-idf/components/esp_driver_rmt/src/**)", + "Read(//Users/ewoud/esp/esp-idf/components/esp_lcd/i80/**)", + "Read(//dev/**)", + "WebFetch(domain:components.espressif.com)", + "WebFetch(domain:components101.com)", + "WebFetch(domain:developer.espressif.com)", + "WebFetch(domain:devices.esphome.io)", + "WebFetch(domain:docs.espressif.com)", + "WebFetch(domain:easyelecmodule.com)", + "WebFetch(domain:files.waveshare.com)", + "WebFetch(domain:github.com)", + "WebFetch(domain:kno.wled.ge)", + "WebFetch(domain:mm.kno.wled.ge)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:www.digikey.com)", + "WebFetch(domain:www.farnell.com)", + "WebFetch(domain:www.waveshare.com)", + "WebSearch" + ], + "additionalDirectories": [ + "/tmp" + ] + } +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da248ddb..46b8e190 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -392,7 +392,10 @@ jobs: cp src/ui/install-picker.js pages/install/ # library.json — install page reads the project version from it. cp library.json pages/install/ - ls -la pages/install/ + # Root landing page (moonmodules.org/projectMM/) → Flash button + repo + # links. Without it the bare root 404s; only /install/ would exist. + cp docs/landing/index.html pages/index.html + ls -la pages/ pages/install/ - uses: actions/upload-pages-artifact@v3 with: diff --git a/CLAUDE.md b/CLAUDE.md index 779a0340..edeeab92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,14 +8,16 @@ See `docs/architecture.md` for system design. This file contains only rules and ## Principles -- **Common patterns first.** This repo is meant to be a recognisable example of good practice across code, docs, tests, and UI — not a Frankenstein of bespoke conventions only the authors understand. Hold every decision against it, especially in core architecture and documentation. Before introducing a pattern, name a widely-used project / framework / canonical resource that uses it; if you can't, treat it as bespoke and justify the divergence in a one-line comment at the introduction site. A new contributor with general C++/web experience should recognise the pattern within 30 seconds. Bespoke choices are allowed — header-only light modules, the MoonModule lifecycle, present-tense docs — but each carries its reason at the place it's introduced. +- **Common patterns first.** This repo is meant to be a recognisable example of good practice across code, docs, tests, and UI, not a Frankenstein of bespoke conventions only the authors understand. Hold every decision against it, especially in core architecture and documentation. Before introducing a pattern, name a widely-used project / framework / canonical resource that uses it; if you can't, treat it as bespoke and justify the divergence in a one-line comment at the introduction site. A new contributor with general C++/web experience should recognise the pattern within 30 seconds. Bespoke choices are allowed (header-only light modules, the MoonModule lifecycle, present-tense docs), but each carries its reason at the place it's introduced. +- **Industry standards, our own code.** Reach for the established, recognisable solution: the textbook *algorithm* (a DC-blocker high-pass, a Hann window, RMS, an integer-square-root) AND the textbook *name* for every variable, function, and UI control. That's *Common patterns first* applied to both domains, core and light: take the textbook approach over a clever or borrowed one. Study the prior art hard, whatever sharpens our thinking: repos, datasheets, vendor sites. Respect it, learn from it, credit it by name in the `history/` digests and per-module "Prior art" sections. Then write every line fresh against our own architecture: **carry the ideas forward, but write our own code rather than copying theirs or tracing their structure.** The method that guarantees it: spec from primary sources (ESP32 / peripheral / sensor datasheets, Espressif docs, reference standards), pin the behaviour as tests (unit + scenario), and let the worker-bee agents implement against the process ([CLAUDE.md](CLAUDE.md)) and architecture ([architecture.md](docs/architecture.md)). The result is independent by construction, not a renamed copy. - **Minimalism means simplicity.** Flat, simple, predictable code. Not clever abstractions. Not elegant templates. If a contributor can't understand it in 30 seconds, it's too complex. -- **Core grows slower than the domain.** Adding a domain module is expected growth: a new unit of domain capability adds lines because it adds a feature, and that's fine. The **core** (domain-neutral infrastructure — `src/core/`, the platform layer, the docs that describe them) is held to a higher bar: while the system is still being built the core does grow, but each core change should buy proportionally more than a domain addition — new infrastructure that many modules use, not a one-off. A core change is suspect until that leverage is shown. Watch the ratio — core is meant to be the lean base under a wider domain, not the other way around. -- **Default to subtraction.** The reflex on most changes — a bug fix, a review finding, a refactor — should be *can this remove or replace code, or land net-neutral?*, not *what do I add?* If a change only ever grows the line count and the doc count, that's the smell this rule exists to catch. Prefer removing code over adding it; a deletion that preserves behaviour is the best kind of change. -- **No duplication, in code or docs.** Same logic in two places belongs in one shared function; same fact in two docs belongs in one place the other links to. A comment or doc paragraph that restates what the code already says is duplication too — delete it. (Reuse a recognisable shape rather than inventing one — see *Common patterns first* above.) -- **Data over objects in the hot path.** Where speed and memory matter most, design around plain contiguous data, not an object graph: a flat buffer of elements that one stage writes and the next stage reads, following the producer/consumer data flow in [docs/architecture.md](docs/architecture.md). This is a deliberate performance choice — a contiguous buffer is cache-friendly and lets a stage do integer math straight on the array, whereas per-element objects with virtual accessors are cache-hostile and allocation-heavy, exactly what the hot-path rules forbid. The one deliberate class hierarchy is the module tree (one `MoonModule` base, shallow subclasses, a single virtual-dispatch boundary), because uniform polymorphism is what lets the UI render any module generically with zero per-module UI code. Don't add inheritance elsewhere, and don't wrap hot-path buffer data in objects. +- **Core grows slower than the domain.** Adding a domain module is expected growth: a new unit of domain capability adds lines because it adds a feature, and that's fine. The **core** (domain-neutral infrastructure: `src/core/`, the platform layer, the docs that describe them) is held to a higher bar: while the system is still being built the core does grow, but each core change should buy proportionally more than a domain addition: new infrastructure that many modules use, not a one-off. A core change is suspect until that leverage is shown. Watch the ratio; core is meant to be the lean base under a wider domain, not the other way around. +- **Default to subtraction.** The reflex on most changes (a bug fix, a review finding, a refactor) should be *can this remove or replace code, or land net-neutral?*, not *what do I add?* If a change only ever grows the line count and the doc count, that's the smell this rule exists to catch. Prefer removing code over adding it; a deletion that preserves behaviour is the best kind of change. +- **No duplication, in code or docs.** Same logic in two places belongs in one shared function; same fact in two docs belongs in one place the other links to. A comment or doc paragraph that restates what the code already says is duplication too; delete it. (Reuse a recognisable shape rather than inventing one; see *Common patterns first* above.) +- **Data over objects in the hot path.** Where speed and memory matter most, design around plain contiguous data, not an object graph: a flat buffer of elements that one stage writes and the next stage reads, following the producer/consumer data flow in [docs/architecture.md](docs/architecture.md). This is a deliberate performance choice: a contiguous buffer is cache-friendly and lets a stage do integer math straight on the array, whereas per-element objects with virtual accessors are cache-hostile and allocation-heavy, exactly what the hot-path rules forbid. The one deliberate class hierarchy is the module tree (one `MoonModule` base, shallow subclasses, a single virtual-dispatch boundary), because uniform polymorphism is what lets the UI render any module generically with zero per-module UI code. Don't add inheritance elsewhere, and don't wrap hot-path buffer data in objects. - **Concrete first, abstract later.** Build one working feature end-to-end before extracting patterns into shared abstractions. Don't build the framework before the domain logic works. -- **Robust to any input.** A running device tolerates any sequence of UI actions or API calls — add, delete, replace, or reconfigure any module in any order, at any grid size, and it keeps running. Degraded or idle is acceptable; crashed is not. This robustness is a defining strongpoint of projectMM, and it's guarded by the test framework, not by hope: a discovered crash drives a new test that pins the fix (see the Hard Rule). Out of scope: power loss, malformed OTA, brown-out, and other physical/electrical faults the firmware can't intercept — this principle is about what the software accepts as input. +- **Robust to any input.** A running device tolerates any sequence of UI actions or API calls: add, delete, replace, or reconfigure any module in any order, at any grid size, and it keeps running. Degraded or idle is acceptable; crashed is not. This robustness is a defining strongpoint of projectMM, and it's guarded by the test framework, not by hope: a discovered crash drives a new test that pins the fix (see the Hard Rule). Out of scope: power loss, malformed OTA, brown-out, and other physical/electrical faults the firmware can't intercept; this principle is about what the software accepts as input. +- **No reboot to apply a configuration change.** Every setting takes effect live, on the next render tick — change a pin map, a strand length, an output protocol, a mic pin or rate, anything, on a running device and it just works. There is no init-once-at-boot step, and no *config* change requires a restart, which sets projectMM apart from most LED-controller firmware (where a pin or protocol change means a reboot). Like robustness, this is a defining strongpoint, and it falls out of the architecture for free rather than being hand-built per module: any control whose change reshapes derived state routes through the generic `onBuildState()` rebuild sweep, so drivers, the audio peripheral, effects, layouts, modifiers and network I/O all inherit it. When adding a feature, don't reach for a reboot/restart to apply config; make the change live. Full mechanism + rationale: [architecture.md § Live reconfiguration](docs/architecture.md#live-reconfiguration-every-change-applies-without-a-reboot). The one exception is what you'd expect: a *firmware* OTA flash swaps the binary and needs the usual power cycle — that's not a configuration change, and (like power loss and brown-out) it's the same physical-fault boundary the robustness principle draws. - **Domain-neutral core.** Separate core infrastructure from the light domain as much as practical. When mixing is necessary, use domain-neutral naming so the code stays open to future separation. - **Present tense only.** Code, comments, and documentation describe the system as it is now. No changelogs, no roadmaps. History lives in git commits. Exceptions: `docs/backlog/` (forward-looking) and `docs/history/` (backward-looking). @@ -25,39 +27,39 @@ The design rationale for each rule below lives in [docs/architecture.md](docs/ar **Tests must pass.** Run `ctest` (unit tests) and `uv run scripts/scenario/run_scenario.py` (scenarios) before considering work complete. The Python wrapper invokes the C++ runner and persists per-target observations back to each scenario JSON (drift visibility); direct `./build/test/mm_scenarios` is fine for ad-hoc pass/fail checks but skips the JSON write-back. New core logic needs a corresponding module test. Full pipeline needs a scenario test. See [docs/testing.md](docs/testing.md) for the strategy and test inventory. -**Warnings are errors.** Build with `-Wall -Wextra -Werror`. No warning is "harmless" — if it's noise, fix it or silence it explicitly with a `-Wno-…` justified in code. +**Warnings are errors.** Build with `-Wall -Wextra -Werror`. No warning is "harmless"; if it's noise, fix it or silence it explicitly with a `-Wno-…` justified in code. **Platform boundary.** No `#ifdef`, platform-specific `#include`, or hardware API call outside `src/platform/`. Compile-time platform branching uses `if constexpr` on `platform_config.h` flags. Full rule: [architecture.md § Platform Abstraction](docs/architecture.md#platform-abstraction). -**Hot path discipline.** In the render loop and anything it calls: no heap allocations (`new`, `malloc`, `push_back`, `std::string`), no blocking (`delay`, `sleep`, `mutex.lock()` — use `try_lock`), integer math preferred over `float` per-light. Memory: single contiguous blocks outside the hot path, PSRAM via `heap_caps_malloc(..., MALLOC_CAP_SPIRAM)` for large buffers. Network input: synchronous by default. Full rules + rationale: [architecture.md § Hot path discipline](docs/architecture.md#hot-path-discipline). +**Hot path discipline.** In the render loop and anything it calls: no heap allocations (`new`, `malloc`, `push_back`, `std::string`), no blocking (`delay`, `sleep`, `mutex.lock()`; use `try_lock`), integer math preferred over `float` per-light. Memory: single contiguous blocks outside the hot path, PSRAM via `heap_caps_malloc(..., MALLOC_CAP_SPIRAM)` for large buffers. Network input: synchronous by default. Full rules + rationale: [architecture.md § Hot path discipline](docs/architecture.md#hot-path-discipline). **Effects must run at every grid size and tick rate.** No crash on 0×0×0; animation math doesn't truncate to zero on fast devices. Full rule + rationale: [architecture.md § Effects](docs/architecture.md#effects). -**Robust to any input.** No UI action or API-call sequence crashes or wedges a running device — including deleting, replacing, or clearing modules in any order. A crash or hang is a bug, not the user's fault; the fix is incomplete until a test reproduces the sequence. Full rule + rationale: [architecture.md § Robustness](docs/architecture.md#robustness). +**Robust to any input.** No UI action or API-call sequence crashes or wedges a running device, including deleting, replacing, or clearing modules in any order. A crash or hang is a bug, not the user's fault; the fix is incomplete until a test reproduces the sequence. Full rule + rationale: [architecture.md § Robustness](docs/architecture.md#robustness). ## Process Rules **Specs before code.** Module docs (`docs/moonmodules/*.md`) and the UI spec must be sufficient to implement from before writing code. What's sufficient is case by case. When in doubt, ask. -**Ask, don't guess.** When uncertain about requirements, behavior, or approach — ask the product owner. Asking is always preferred over guessing. This is the default. +**Ask, don't guess.** When uncertain about requirements, behavior, or approach, ask the product owner. Asking is always preferred over guessing. This is the default. -**Sanity-check every request before acting.** When the product owner asks for something, first hold it against three references: does the request make sense, and does it align with [README.md](README.md), this [CLAUDE.md](CLAUDE.md), and [docs/architecture.md](docs/architecture.md)? If it does, proceed. If it doesn't — if the request contradicts a principle, breaks a hard rule, fights the architecture, or just doesn't add up given context the product owner may have missed — push back briefly before doing the work: name what looks off, name which doc says what, and offer the alternative. The product owner can still say "do it anyway" (they often have context the agent doesn't), but the check has happened. This catches bad decisions early instead of after the diff lands. +**Sanity-check every request before acting.** When the product owner asks for something, first hold it against three references: does the request make sense, and does it align with [README.md](README.md), this [CLAUDE.md](CLAUDE.md), and [docs/architecture.md](docs/architecture.md)? If it does, proceed. If it doesn't, if the request contradicts a principle, breaks a hard rule, fights the architecture, or just doesn't add up given context the product owner may have missed, push back briefly before doing the work: name what looks off, name which doc says what, and offer the alternative. The product owner can still say "do it anyway" (they often have context the agent doesn't), but the check has happened. This catches bad decisions early instead of after the diff lands. **Refactor for simplicity.** When the product owner asks to make something simpler, more consistent, or "cleaner," do not start moving files or rewriting code until three questions are answered in writing: 1. **Enumerate alternatives.** List the 2–4 plausible end states. One line each (e.g. "A: split into core/light", "B: split into core/light/platform", "C: keep flat with naming convention"). 2. **For each, name what's gained and what's lost.** Concrete and measurable: lines removed, ambiguities resolved, duplicated parsers eliminated, contributor friction added (every extra subfolder is friction; every empty placeholder dir is friction; every renamed-but-unused alias is friction). -3. **Pick the leanest that solves the actual problem.** Subtraction beats addition. An empty subfolder, a parser duplicated "for clarity", a renamed alias kept "for compatibility" all add friction without paying for themselves — don't propose them. +3. **Pick the leanest that solves the actual problem.** Subtraction beats addition. An empty subfolder, a parser duplicated "for clarity", a renamed alias kept "for compatibility" all add friction without paying for themselves; don't propose them. 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. -**Use `uv` for every Python invocation.** Never type `python` or `python3` directly — always go through `uv run` (e.g. `uv run scripts/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 ([scripts/MoonDeck.md](scripts/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`. +**Use `uv` for every Python invocation.** Never type `python` or `python3` directly; always go through `uv run` (e.g. `uv run scripts/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 ([scripts/MoonDeck.md](scripts/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`. -The **one exception** is `esp32/main/CMakeLists.txt`: ESP-IDF builds use IDF's bundled Python venv, not the project venv — adding uv to ESP-IDF docker would be a bigger CI lift than the portability win pays for. That file uses `find_package(Python3 REQUIRED COMPONENTS Interpreter)` and invokes `${Python3_EXECUTABLE}`, so CMake locates whichever Python IDF set up (`.venv\Scripts\python.exe` on Windows IDF, `.venv/bin/python3` on macOS/Linux IDF). The shared `src/ui/embed_ui.cmake` script takes a `PYTHON_CMD` parameter that callers pass: desktop passes `${UV_EXECUTABLE};run;python`, ESP32 passes `${Python3_EXECUTABLE}`. +The **one exception** is `esp32/main/CMakeLists.txt`: ESP-IDF builds use IDF's bundled Python venv, not the project venv; adding uv to ESP-IDF docker would be a bigger CI lift than the portability win pays for. That file uses `find_package(Python3 REQUIRED COMPONENTS Interpreter)` and invokes `${Python3_EXECUTABLE}`, so CMake locates whichever Python IDF set up (`.venv\Scripts\python.exe` on Windows IDF, `.venv/bin/python3` on macOS/Linux IDF). The shared `src/ui/embed_ui.cmake` script takes a `PYTHON_CMD` parameter that callers pass: desktop passes `${UV_EXECUTABLE};run;python`, ESP32 passes `${Python3_EXECUTABLE}`. -**Consider extending before creating.** When adding a feature, check if an existing module can be extended cleanly. If a new file is genuinely cleaner, that's fine — but justify it. +**Consider extending before creating.** When adding a feature, check if an existing module can be extended cleanly. If a new file is genuinely cleaner, that's fine, but justify it. **Do not remove comments** unless they are outdated or factually wrong. Comments document intent and context. Removing them silently loses knowledge. @@ -65,7 +67,7 @@ The **one exception** is `esp32/main/CMakeLists.txt`: ESP-IDF builds use IDF's b **Git: only on explicit request.** Do not `git add`, `git commit`, or `git push` on your own initiative. Only execute these when the product owner explicitly asks (e.g. "commit now", "push it"). The product owner controls when changes are staged, committed, and pushed. -**Gate lists are initiated by the product owner, not by agents. Never start a gate list on your own — always ask first.** The full set of gates (especially the Opus reviewer at PR-merge and the ESP32 build at commit) easily takes 10 minutes per event and burns real tokens; agents must not initiate them unprompted. When the product owner says "run pre-commit" / "commit now" / "pre-merge" / "ready to release" the relevant list below runs. Do NOT start any list automatically because you finished a feature, because tests pass, because a milestone feels reached, because completion seems imminent, or because an earlier instruction implied a sequence ending in "commit". If you finished feature work and are unsure whether the product owner wants to proceed, **ask**: "Feature work is done — should I run pre-commit, or do you want to look first?" Treat each list as a gate the product owner opens; the agent's job is finishing the work and reporting status so the product owner can decide when to open it. +**Gate lists are initiated by the product owner, not by agents. Never start a gate list on your own; always ask first.** The full set of gates (especially the Opus reviewer at PR-merge and the ESP32 build at commit) easily takes 10 minutes per event and burns real tokens; agents must not initiate them unprompted. When the product owner says "run pre-commit" / "commit now" / "pre-merge" / "ready to release" the relevant list below runs. Do NOT start any list automatically because you finished a feature, because tests pass, because a milestone feels reached, because completion seems imminent, or because an earlier instruction implied a sequence ending in "commit". If you finished feature work and are unsure whether the product owner wants to proceed, **ask**: "Feature work is done, should I run pre-commit, or do you want to look first?" Treat each list as a gate the product owner opens; the agent's job is finishing the work and reporting status so the product owner can decide when to open it. The full gate lists per lifecycle event (commit, push, PR merge, release) live in **Lifecycle Events** below. @@ -79,65 +81,65 @@ Each commit produces visible output. The product owner picks what to build next. ### Per-feature workflow -1. **Pick what to build.** One layout, one effect, one driver, one modifier, one system module — whatever adds the next useful capability. +1. **Pick what to build.** One layout, one effect, one driver, one modifier, one system module: whatever adds the next useful capability. 2. **Review only the relevant module drafts.** Select from `docs/backlog/moonmodules_draft/`. Promote only what's needed to `docs/moonmodules/`. -3. **`/plan` it.** Plan references only the promoted specs + architecture docs. Plans are not promoted to the repo — the implemented code, docs, and commit message together describe what landed. +3. **`/plan` it.** Plan references only the promoted specs + architecture docs. Plans are not promoted to the repo; the implemented code, docs, and commit message together describe what landed. 4. **Implement in a branch** (`next-iteration` or feature branch). Test on hardware. Run the commit gates (see Lifecycle Events below). Commit. 5. **Push.** Product owner pushes. CodeRabbit reviews the PR. Process findings. 6. **Repeat.** ### Lifecycle Events -The project has **three** gated lifecycle events: **commit**, **PR merge into `main`**, **release tag**. (Push has no gate of its own — every check that needs to land before code goes out either lives in the commit gates or is the CodeRabbit / human PR review.) Each event has its own checklist below. Gates within a list run **only when the change makes them applicable** — every conditional gate states its trigger objectively (e.g. "any file under `src/` changed"). A gate that doesn't apply is skipped; a gate that *does* apply but the product owner chooses to skip must have a one-line reason in the commit body / PR description / release notes. The trail stays honest and auditable. +The project has **three** gated lifecycle events: **commit**, **PR merge into `main`**, **release tag**. (Push has no gate of its own: every check that needs to land before code goes out either lives in the commit gates or is the CodeRabbit / human PR review.) Each event has its own checklist below. Gates within a list run **only when the change makes them applicable**: every conditional gate states its trigger objectively (e.g. "any file under `src/` changed"). A gate that doesn't apply is skipped; a gate that *does* apply but the product owner chooses to skip must have a one-line reason in the commit body / PR description / release notes. The trail stays honest and auditable. -Initiation is always the product owner's call — see the rule above. Agents never start a list on their own. +Initiation is always the product owner's call; see the rule above. Agents never start a list on their own. -#### Event 1 — Commit +#### Event 1: Commit 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. **Conditional (run if trigger matches):** -2. Desktop build — `cmake --build build` (zero warnings) — if any file that compiles into the desktop binary changed: `src/`, `test/`, `CMakeLists.txt` (root or `test/`), `library.json`. A YAML / docs / `scripts/` / `.claude/` change cannot break the build. -3. Unit tests — `ctest --output-on-failure` (all pass) — same trigger as Desktop build. No build, no tests. -4. Scenario tests — `uv run scripts/scenario/run_scenario.py` (all pass; wraps `mm_scenarios` and persists per-target `observed.` blocks back to each scenario JSON for drift tracking) — same trigger as Desktop build, plus any `test/scenarios/*.json` change. -5. Platform boundary — `check_platform_boundary.py` — if any file under `src/` (excluding `src/platform/`) changed. -6. ESP32 build — `build_esp32.py` — if any file under `src/` (excluding `src/platform/desktop/`), `esp32/`, `CMakeLists.txt`, or `library.json` changed. -7. KPI collection — `collect_kpi.py --commit` — if any file under `src/` changed. **The one-liner MUST include `tick:Xus(FPS:Y)` for every supported target** (PC + ESP32 today; Teensy/RPi when added). If a target's tick/FPS is missing — e.g. ESP32 wasn't monitored recently and `esp32/monitor.log` is stale — re-run a short live capture before committing, or note explicitly in the commit body why the value is absent. +2. Desktop build, `cmake --build build` (zero warnings), if any file that compiles into the desktop binary changed: `src/`, `test/`, `CMakeLists.txt` (root or `test/`), `library.json`. A YAML / docs / `scripts/` / `.claude/` change cannot break the build. +3. Unit tests, `ctest --output-on-failure` (all pass), same trigger as Desktop build. No build, no tests. +4. Scenario tests, `uv run scripts/scenario/run_scenario.py` (all pass; wraps `mm_scenarios` and persists per-target `observed.` blocks back to each scenario JSON for drift tracking), same trigger as Desktop build, plus any `test/scenarios/*.json` change. +5. Platform boundary, `check_platform_boundary.py`, if any file under `src/` (excluding `src/platform/`) changed. +6. ESP32 build, `build_esp32.py`, if any file under `src/` (excluding `src/platform/desktop/`), `esp32/`, `CMakeLists.txt`, or `library.json` changed. +7. KPI collection, `collect_kpi.py --commit`, if any file under `src/` changed. **The one-liner MUST include `tick:Xus(FPS:Y)` for every supported target** (PC + ESP32 today; Teensy/RPi when added). If a target's tick/FPS is missing (e.g. ESP32 wasn't monitored recently and `esp32/monitor.log` is stale), re-run a short live capture before committing, or note explicitly in the commit body why the value is absent. -A commit that touches *only* `.github/`, `docs/`, `scripts/` (non-test), `README.md`, `CLAUDE.md`, or `.claude/` therefore runs only the spec check — the rest are no-ops because their triggers don't fire. This is the intended pre-commit cost for CI-only or doc-only changes. +A commit that touches *only* `.github/`, `docs/`, `scripts/` (non-test), `README.md`, `CLAUDE.md`, or `.claude/` therefore runs only the spec check; the rest are no-ops because their triggers don't fire. This is the intended pre-commit cost for CI-only or doc-only changes. **Recommended (manual, not blocking):** -- **Improv smoke test** — `uv run scripts/build/improv_smoke_test.py --port ` (or MoonDeck → ESP32 → **Improv Smoke Test**) — recommended when a connected ESP32 is available and any of these changed: `src/core/ImprovFrame.h`, `src/platform/esp32/platform_esp32_improv.cpp`, `docs/install/index.html`, `src/ui/install-picker.js`, `scripts/build/improv_*.py`. Three-step end-to-end check (probe + WiFi provision + LAN reachability). Not a blocking gate because it needs hardware that isn't always plugged in; pair with `preview_installer`'s flash-ready mode for the browser-side equivalent. +- **Improv smoke test**, `uv run scripts/build/improv_smoke_test.py --port ` (or MoonDeck → ESP32 → **Improv Smoke Test**), recommended when a connected ESP32 is available and any of these changed: `src/core/ImprovFrame.h`, `src/platform/esp32/platform_esp32_improv.cpp`, `docs/install/index.html`, `src/ui/install-picker.js`, `scripts/build/improv_*.py`. Three-step end-to-end check (probe + WiFi provision + LAN reachability). Not a blocking gate because it needs hardware that isn't always plugged in; pair with `preview_installer`'s flash-ready mode for the browser-side equivalent. **After all gates pass:** stop and wait for the product owner to explicitly say "commit now" (or equivalent). Do not commit on your own initiative. -**When "commit now" is received** — compile the commit message in this format and execute the commit: - -8. Commit message format (the structure below uses hard newlines *between* its parts — title, summary, KPI line, bullets are each their own line. But do **not** hard-wrap *within* a part: the summary paragraph and each bullet are a single unbroken line that the viewer soft-wraps — same reasoning as the no-hard-wraps-in-markdown rule in [coding-standards.md](docs/coding-standards.md), keeps diffs clean and renders correctly on GitHub. Only the title obeys a length cap; everything else runs as long as it needs to on one line): - - **Title line** — short imperative summary of the change (≤ 72 chars), e.g. `Add MirrorModifier and fix PreviewDriver sampling` - - **Short summary** — a TL;DR for the commit: 1–3 sentences max, end-user readable, plain language. State *what* changed and *why* at the level a release-notes reader cares about — do NOT recap the change sections that follow (the bullets do that), and do NOT enumerate files. If your draft is longer than three sentences or restates section headers, cut it. A reader who only sees the title + this paragraph should know what shipped and why. - - **KPI one-liner** — the `tick:Xus(FPS:Y)` line from step 7. Omit if the KPI gate didn't run (no `src/` changes). - - **Change sections** — one section per applicable category below; omit a section entirely if nothing in that area changed. Each section is a bulleted list, one bullet per module/file, in your own words. **Core and Light domain are the preferred default categories** — a test for a core module goes under Core, a script fix that touches a light driver goes under Light domain. Only use the other categories for changes that have no meaningful connection to Core or Light domain: - - **Core** (`src/core/`, `src/platform/`) — e.g. `- HttpServerModule: added 409 guard to prevent overlapping OTA jobs` - - **Light domain** (`src/light/`) — e.g. `- PreviewDriver: replaced strided sampling with max-pooling to fix empty frames` - - **UI** (`src/ui/`) — e.g. `- app.js: auto-fit camera distance on first preview frame` - - **Scripts / MoonDeck** (`scripts/`) — e.g. `- MoonDeck: added per-scenario dropdown to scenario card` - - **Tests** (`test/`) — e.g. `- test_preview_driver: updated default assertions for new fps/detail/decompress values` - - **Docs / CI** (`docs/`, `README.md`, `CLAUDE.md`, `.github/`, `CMakeLists.txt`) — e.g. `- README: consolidated ESP32 install info to web installer` - - **Reviews** — if any review findings were processed in this commit, one bullet per finding, prefixed by reviewer: 🐇 for CodeRabbit findings, 👾 for internal Reviewer agent findings. Each bullet states what was flagged, what was done (fixed / accepted / deferred), and — if not fixed — why. Omit if no review findings were processed. +**When "commit now" is received**, compile the commit message in this format and execute the commit: + +8. Commit message format (the structure below uses hard newlines *between* its parts: title, summary, KPI line, bullets are each their own line. But do **not** hard-wrap *within* a part: the summary paragraph and each bullet are a single unbroken line that the viewer soft-wraps, same reasoning as the no-hard-wraps-in-markdown rule in [coding-standards.md](docs/coding-standards.md), keeps diffs clean and renders correctly on GitHub. Only the title obeys a length cap; everything else runs as long as it needs to on one line): + - **Title line**: short imperative summary of the change (≤ 72 chars), e.g. `Add MirrorModifier and fix PreviewDriver sampling` + - **Short summary**: a TL;DR for the commit: 1–3 sentences max, end-user readable, plain language. State *what* changed and *why* at the level a release-notes reader cares about; do NOT recap the change sections that follow (the bullets do that), and do NOT enumerate files. If your draft is longer than three sentences or restates section headers, cut it. A reader who only sees the title + this paragraph should know what shipped and why. + - **KPI one-liner**: the `tick:Xus(FPS:Y)` line from step 7. Omit if the KPI gate didn't run (no `src/` changes). + - **Change sections**: one section per applicable category below; omit a section entirely if nothing in that area changed. Each section is a bulleted list, one bullet per module/file, in your own words. **Core and Light domain are the preferred default categories**: a test for a core module goes under Core, a script fix that touches a light driver goes under Light domain. Only use the other categories for changes that have no meaningful connection to Core or Light domain: + - **Core** (`src/core/`, `src/platform/`): e.g. `- HttpServerModule: added 409 guard to prevent overlapping OTA jobs` + - **Light domain** (`src/light/`): e.g. `- PreviewDriver: replaced strided sampling with max-pooling to fix empty frames` + - **UI** (`src/ui/`): e.g. `- app.js: auto-fit camera distance on first preview frame` + - **Scripts / MoonDeck** (`scripts/`): e.g. `- MoonDeck: added per-scenario dropdown to scenario card` + - **Tests** (`test/`): e.g. `- test_preview_driver: updated default assertions for new fps/detail/decompress values` + - **Docs / CI** (`docs/`, `README.md`, `CLAUDE.md`, `.github/`, `CMakeLists.txt`): e.g. `- README: consolidated ESP32 install info to web installer` + - **Reviews**: if any review findings were processed in this commit, one bullet per finding, prefixed by reviewer: 🐇 for CodeRabbit findings, 👾 for internal Reviewer agent findings. Each bullet states what was flagged, what was done (fixed / accepted / deferred), and, if not fixed, why. Omit if no review findings were processed. - Full KPI details block at the bottom (as produced by `collect_kpi.py`) **Not at commit-time** (these run at PR-merge): Reviewer agent; live perf analysis + `docs/performance.md` update; documentation sync sweep; permission review. -**On-demand reviewer.** The product owner can ask for a reviewer pass mid-branch on a single risky commit — say "run reviewer" before commit — and the agent runs it on the staged diff with the same scope as the merge-time gate. Default is fast (no reviewer); the on-demand path is a safety valve when something specific feels off. +**On-demand reviewer.** The product owner can ask for a reviewer pass mid-branch on a single risky commit (say "run reviewer" before commit), and the agent runs it on the staged diff with the same scope as the merge-time gate. Default is fast (no reviewer); the on-demand path is a safety valve when something specific feels off. -#### Event 2 — PR merge into `main` +#### Event 2: PR merge into `main` The "this is now trunk" moment. Where the wider hygiene checks live, because once it's in trunk it gets shipped. @@ -145,33 +147,33 @@ The "this is now trunk" moment. Where the wider hygiene checks live, because onc 1. All commit gates passed on every commit in the PR. 2. PR feedback addressed (CodeRabbit + human review). -3. **Carry forward lessons** — if the branch produced a hard-won lesson, a proven pattern, or a non-obvious decision worth keeping, note it in `docs/history/decisions.md` as part of the branch's commits — so the lesson lands in `main` with the code that proved it. Do this on the branch before the merge commit. -4. **Documentation sync** — every new module / control / API endpoint has matching docs (`docs/moonmodules/*.md`, `docs/testing.md`, `docs/architecture*.md`). -5. **Reviewer agent** — trigger this **first** so it runs while the other checks (docs sync, carry-forward lessons, conditional gates) proceed in parallel. Opus reviewer over the **whole branch diff** (`git diff main...HEAD`). Scope: domain boundary, **common patterns first** (flag any new convention — naming scheme, file shape, build flag, control mechanism, UI affordance — that isn't recognisable from a widely-used project / framework / canonical resource; bespoke choices must carry a stated reason at the introduction site, see the principle in § Principles), **unnecessary abstractions** (no-op / pass-through wrappers that only rename or re-namespace an existing function, single-call-site indirection that would read clearer inlined, names that obscure where the real code lives), **duplicated patterns** (same logic in multiple places that belongs in a base class or shared function), hot-path violations, spec conformance, bloat, platform boundary. Architectural drift is more visible across N commits than across one — "three commits each added a wrapper" reads as a pattern that one commit hides. Findings either get fixed in additional branch commits before merge, or are accepted with a one-line reason in the PR description. CodeRabbit complements this — CodeRabbit handles line-level bugs in the PR; the Reviewer agent handles architectural drift. -6. **PR title and description** — review and update if the work done differs from what the PR title/description says. The description is the permanent record of what landed and why; it should reflect the actual diff, not the original intent. +3. **Carry forward lessons**: if the branch produced a hard-won lesson, a proven pattern, or a non-obvious decision worth keeping, note it in `docs/history/decisions.md` as part of the branch's commits, so the lesson lands in `main` with the code that proved it. Do this on the branch before the merge commit. +4. **Documentation sync**: every new module / control / API endpoint has matching docs (`docs/moonmodules/*.md`, `docs/testing.md`, `docs/architecture*.md`). +5. **Reviewer agent**: trigger this **first** so it runs while the other checks (docs sync, carry-forward lessons, conditional gates) proceed in parallel. Opus reviewer over the **whole branch diff** (`git diff main...HEAD`). Scope: domain boundary, **common patterns first** (flag any new convention (naming scheme, file shape, build flag, control mechanism, UI affordance) that isn't recognisable from a widely-used project / framework / canonical resource; bespoke choices must carry a stated reason at the introduction site, see the principle in § Principles), **unnecessary abstractions** (no-op / pass-through wrappers that only rename or re-namespace an existing function, single-call-site indirection that would read clearer inlined, names that obscure where the real code lives), **duplicated patterns** (same logic in multiple places that belongs in a base class or shared function), hot-path violations, spec conformance, bloat, platform boundary. Architectural drift is more visible across N commits than across one: "three commits each added a wrapper" reads as a pattern that one commit hides. Findings either get fixed in additional branch commits before merge, or are accepted with a one-line reason in the PR description. CodeRabbit complements this: CodeRabbit handles line-level bugs in the PR; the Reviewer agent handles architectural drift. +6. **PR title and description**: review and update if the work done differs from what the PR title/description says. The description is the permanent record of what landed and why; it should reflect the actual diff, not the original intent. **Conditional:** -7. **Live perf snapshot** — `docs/performance.md` updated — if the branch touches anything under `src/light/`, `src/core/Scheduler.h`, `src/core/HttpServerModule.cpp`, or any platform code that runs in the tick path. Compare new tick/FPS to the previous committed values; explain significant changes. -8. **Permission review** — scan `.claude/settings.local.json`. The `allow` list grows organically and accumulates one-off entries (specific `sed` line ranges, one-time `lldb` invocations, `/tmp/probe` paths) that will never recur. Propose to the product owner: (a) one-off entries that can be deleted, and (b) clusters of narrow entries that could collapse into one broad pattern (e.g. several `./build/test/mm_tests -tc="..."` lines → `Bash(./build/test/mm_tests:*)`) so routine commands stop prompting. Advisory — agent suggests, product owner approves. Never broaden permissions for destructive or network-mutating commands without explicit approval; err toward keeping the list tight. Not commit-blocking but always worth doing once per merge since this is when noise has accumulated. -9. **README + quick-start refresh** — if the change altered build, flash, or first-run UX. +7. **Live perf snapshot**: `docs/performance.md` updated, if the branch touches anything under `src/light/`, `src/core/Scheduler.h`, `src/core/HttpServerModule.cpp`, or any platform code that runs in the tick path. Compare new tick/FPS to the previous committed values; explain significant changes. +8. **Permission review**: scan `.claude/settings.local.json`. The `allow` list grows organically and accumulates one-off entries (specific `sed` line ranges, one-time `lldb` invocations, `/tmp/probe` paths, hard-coded device-IP curls, bare `python3 -c` scratch) that will never recur. Propose to the product owner: (a) one-off entries that can be deleted, and (b) clusters of narrow entries that could collapse into one broad pattern (e.g. several `./build/test/mm_tests -tc="..."` lines → `Bash(./build/test/mm_tests:*)`) so routine commands stop prompting. Advisory: agent suggests, product owner approves. Never broaden permissions for destructive or network-mutating commands without explicit approval; err toward keeping the list tight. **After the product owner approves the cleaned list, immediately `cp .claude/settings.local.json .claude/settings.local.cleaned.json` to save a reference snapshot, and commit `settings.local.cleaned.json` (it is *tracked*, unlike the gitignored live file).** Reason: Claude Code auto-appends a new `allow` entry for every fresh command shape it runs, so the live file silently re-accumulates noise *during the very cleanup* and between merges — it looks like the cleanup "reset back," but it's new session churn, not a revert. The committed snapshot is the canonical clean baseline to diff against and `cp` back from next time, immune to that churn. Not commit-blocking but always worth doing once per merge since this is when noise has accumulated. +9. **README + quick-start refresh**: if the change altered build, flash, or first-run UX. -#### Event 3 — Release tag +#### Event 3: Release tag The "end users will use this" moment. Per-release criteria are defined by the product owner; this is the generic envelope. **Mandatory:** 1. All PR-merge gates passed on the trunk commit being tagged. -2. **Real hardware test** — at minimum one ESP32, plus any other target the release claims to support. Cannot be agent-verified; **product owner only**. -3. **No known critical bugs** — open issues reviewed; any flagged "release-blocker" closed or downgraded. -4. **Per-release criteria** — every release criterion set by the product owner for this tag is done. +2. **Real hardware test**: at minimum one ESP32, plus any other target the release claims to support. Cannot be agent-verified; **product owner only**. +3. **No known critical bugs**: open issues reviewed; any flagged "release-blocker" closed or downgraded. +4. **Per-release criteria**: every release criterion set by the product owner for this tag is done. **Conditional:** -5. **Changelog / release notes** — drafted in the GitHub release body. Skip only for unreleased pre-1.0 tags. -6. **Cross-platform smoke** — run scenarios on every supported platform (today: PC + ESP32; later: + Teensy, RPi) — if the release claims new platform support or the version bumps a major or minor. -7. **Principles audit** — sweep `docs/` (except `docs/backlog/` and `docs/history/`) and `src/` for forward-looking language ("roadmap", "will be", "planned", "in the future", "currently lacks", `TODO`, `FIXME`) and other violations of § Principles. Acceptable hits carry a one-line justification; the rest get rewritten present-tense or moved to `docs/backlog/backlog.md` / `docs/history/`. The reviewer agent can run this end-to-end. Skip only for releases where the diff against the previous tag is doc-empty. +5. **Changelog / release notes**: drafted in the GitHub release body. Skip only for unreleased pre-1.0 tags. +6. **Cross-platform smoke**: run scenarios on every supported platform (today: PC + ESP32; later: + Teensy, RPi), if the release claims new platform support or the version bumps a major or minor. +7. **Principles audit**: sweep `docs/` (except `docs/backlog/` and `docs/history/`) and `src/` for forward-looking language ("roadmap", "will be", "planned", "in the future", "currently lacks", `TODO`, `FIXME`) and other violations of § Principles. Acceptable hits carry a one-line justification; the rest get rewritten present-tense or moved to `docs/backlog/backlog.md` / `docs/history/`. The reviewer agent can run this end-to-end. Skip only for releases where the diff against the previous tag is doc-empty. What the agent reads: - Always: `CLAUDE.md`, `architecture.md` @@ -204,45 +206,45 @@ Documentation describes the system as it is. Git commits are the history. Module **Module specs are end-user / API-integrator documentation, not tech documentation.** Each `docs/moonmodules/.md` page exists to answer "what does this module do that I can't trivially read off the source file?" Concretely, it should carry: -- **Wire contracts** — REST URLs, JSON shapes, status codes, WebSocket framing, binary frame layouts. Anything an integrator outside the codebase needs. -- **Cross-domain wiring** — how this module connects to other modules through plain data structures (e.g. `HttpServerModule` reads a `PreviewFrame` that `PreviewDriver` writes; the wiring happens in `main.cpp`). Things that span multiple files and don't belong as a comment in any single one. -- **Prior art** — the v1/v2/MoonLight lineage links. History/credits the code can't carry. -- **At minimum, one mention of every control name** — `scripts/check/check_specs.py` enforces this, so the spec stays minimally accurate to the source. +- **Wire contracts**: REST URLs, JSON shapes, status codes, WebSocket framing, binary frame layouts. Anything an integrator outside the codebase needs. +- **Cross-domain wiring**: how this module connects to other modules through plain data structures (e.g. `HttpServerModule` reads a `PreviewFrame` that `PreviewDriver` writes; the wiring happens in `main.cpp`). Things that span multiple files and don't belong as a comment in any single one. +- **Prior art**: the v1/v2/MoonLight lineage links. History/credits the code can't carry. +- **At minimum, one mention of every control name**: `scripts/check/check_specs.py` enforces this, so the spec stays minimally accurate to the source. -Do **not** repeat facts the `.h` already states: the controls list (the .h has `controls_.addX(...)`), the method signatures (they're declared), the implementation strategy ("uses a TcpServer abstraction" — visible in the includes), or architectural rules that belong in `architecture.md` (domain boundary, hot-path discipline, etc.). When in doubt: if a fact is visible in the file's `.h`, the `.md` can drop it. The spec-check script and a comment header in the `.h` together carry the contract; the `.md` carries what the file can't. +Do **not** repeat facts the `.h` already states: the controls list (the .h has `controls_.addX(...)`), the method signatures (they're declared), the implementation strategy ("uses a TcpServer abstraction", visible in the includes), or architectural rules that belong in `architecture.md` (domain boundary, hot-path discipline, etc.). When in doubt: if a fact is visible in the file's `.h`, the `.md` can drop it. The spec-check script and a comment header in the `.h` together carry the contract; the `.md` carries what the file can't. -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. **We study these projects to sharpen our own designs — we read their approaches and learn from their decisions; we do not copy their code.** Everything that lands in projectMM is our own implementation, written against our own architecture. A good idea doesn't care who has it: we let these projects shape how we think, and we always respect their licenses by writing our own code rather than lifting theirs. When a specific project or person inspires something here, we credit them by name — the `history/` digests and the per-module "Prior art" sections are where that attribution lives. +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. -The `backlog/` folder is its forward-looking counterpart: `backlog.md` is the prioritised to-build list, and `moonmodules_draft/` holds specs for modules not yet implemented (selected and promoted to `moonmodules/` as each ships, then deleted from the draft). Both `history/` and `backlog/` are exempt from the present-tense rule and agents don't read them automatically — only when planning new work. +The `backlog/` folder is its forward-looking counterpart: `backlog.md` is the prioritised to-build list, and `moonmodules_draft/` holds specs for modules not yet implemented (selected and promoted to `moonmodules/` as each ships, then deleted from the draft). Both `history/` and `backlog/` are exempt from the present-tense rule and agents don't read them automatically; only when planning new work. ## Code Style -All coding conventions — general (`#pragma once`, `constexpr`, `std::span`, namespaces, semantic names, markdown wrapping) and structural (header-only vs `.h` + `.cpp` for light vs core modules, exception-reason comment) — live in [docs/coding-standards.md](docs/coding-standards.md). +All coding conventions, general (`#pragma once`, `constexpr`, `std::span`, namespaces, semantic names, markdown wrapping) and structural (header-only vs `.h` + `.cpp` for light vs core modules, exception-reason comment), live in [docs/coding-standards.md](docs/coding-standards.md). ## Agent Roles -The project uses Claude Code agents in defined roles. The user is the **Product Owner** — the critical success factor in agentic coding. +The project uses Claude Code agents in defined roles. The user is the **Product Owner**, the critical success factor in agentic coding. **What the product owner does:** - Reviews every line of code and every spec change before committing -- Specifies requirements in detail — agents ask, they don't guess +- Specifies requirements in detail; agents ask, they don't guess - Controls staging, committing, and pushing (agents never do this) - Tests on hardware before approving - Decides what to build next and in what order - Catches architectural drift, bloat, and unnecessary complexity early -- Evaluates agent suggestions critically — not everything proposed gets built +- Evaluates agent suggestions critically; not everything proposed gets built -**Why this matters:** Earlier in this project's history, agents had more autonomy. The result was bloat, architectural drift, and compounding bugs. The current approach — tight product owner control with agents as tools, not decision-makers — produces cleaner, more predictable code. The agent writes; the product owner thinks. +**Why this matters:** Earlier in this project's history, agents had more autonomy. The result was bloat, architectural drift, and compounding bugs. The current approach (tight product owner control with agents as tools, not decision-makers) produces cleaner, more predictable code. The agent writes; the product owner thinks. | | Agent | Model | Focus | Does | |--|-------|-------|-------|------| | 🤖 | **Architect** | Opus | System design | Reviews against architecture, designs components, validates boundaries | | 👽 | **Developer** | Sonnet | Implementation | Writes code in worktrees, follows all rules, one step at a time | -| 👾 | **Reviewer** | Opus | Pre-merge check | Runs at PR merge over the whole branch diff (Event 2, gate 5); available on-demand pre-commit on the staged diff when the product owner asks. Complements CodeRabbit (which handles line-level bugs in the PR). | +| 👾 | **Reviewer** | Fable when available, otherwise Opus | Pre-merge check | Runs at PR merge over the whole branch diff (Event 2, gate 5); available on-demand pre-commit on the staged diff when the product owner asks. Complements CodeRabbit (which handles line-level bugs in the PR). | | 🛸 | **Tester** | Sonnet | Verification | Writes tests, verifies architectural rules in code | | 💀 | **Runner** | Haiku | Quick checks | Runs MoonDeck scripts, platform boundary checks, build verification | -Agents work in parallel on independent steps. Agents never commit — only the product owner approves commits after testing. +Agents work in parallel on independent steps. Agents never commit; only the product owner approves commits after testing. ## Build diff --git a/README.md b/README.md index 1013fd14..60c6e46a 100644 --- a/README.md +++ b/README.md @@ -12,52 +12,64 @@ Drive large LED installations and DMX lighting from ESP32, Teensy, Raspberry Pi, Open Chrome or Edge, plug in your board, and you'll see lights in under a minute. -If you like projectMM, give it a ⭐️, fork it, or open an issue or pull request — it helps the project grow, improve, and get noticed. +If you like projectMM, give it a ⭐️, fork it, or open an issue or pull request; it helps the project grow, improve, and get noticed. ## What makes projectMM different -🔵 **16,384 LEDs on a *classic* ESP32** — not just the S3 or P4. Memory-adaptive from a 16×16 panel up to 128×128, degrading gracefully on tight boards instead of crashing. +🔵 **16,384 LEDs on a *classic* ESP32**, not just the S3 or P4. Memory-adaptive from a 16×16 panel up to 128×128, degrading gracefully on tight boards instead of crashing. -🧊 **Native 3D from the ground up** — 2D and 1D are just the cases where a dimension is size 1. Effects never pick a mode. +🧊 **Native 3D from the ground up**: 2D and 1D are just the cases where a dimension is size 1. Effects never pick a mode. -🎛️ **Pluggable pipeline** — Layouts → Layers (effects + modifiers) → Drivers. Build it visually in the browser, configure it live, and it survives reboots. +🎛️ **Pluggable pipeline**: Layouts → Layers (effects + modifiers) → Drivers. Build it visually in the browser, and every change applies live (settings also persist to flash across power cycles). -💡 **DMX *and* addressable LEDs in one setup** — RGB strips, RGBW pixels, par lights, moving heads, all through the same pipeline. +🔄 **No reboot to apply a configuration change**: edit a pin map, a strand length, an output protocol, or the mic on a running device and it takes effect on the very next frame — no init-at-boot step, no restart. Where most LED-controller firmware needs a reboot for a pin or protocol change, projectMM applies it live. (Flashing new *firmware* over OTA still needs the usual power cycle — that's a binary swap, not a config change.) -🖥️ **One source tree, many targets** — the same code runs on ESP32, Teensy, Raspberry Pi, and macOS / Windows / Linux. +💡 **DMX *and* addressable LEDs in one setup**: RGB strips, RGBW pixels, par lights, moving heads, all through the same pipeline. -🎨 **Plug in, open a browser, see lights** — a live 3D preview of every effect, modifier, and layout, controllable from the same tab. The interface renders any module from its declared controls, so adding a module needs zero UI code. +🔌 **Parallel WS2812 output**: drive many strands at once over three ESP32 peripherals — RMT (every chip), the S3's LCD_CAM i80 bus (8 lanes), and the P4's Parlio engine (up to 20 simultaneous strands on the P4) — each with an on-device loopback self-test that bit-verifies the wire signal. -⚡ **Flash from your browser in seconds** — the web installer picks your board, flashes the matching firmware, and hands WiFi credentials to the device over USB via Improv. No serial monitor, no recompile. +🌐 **Industry protocols, both directions**: send *and* receive [Art-Net](https://art-net.org.uk/), [E1.31/sACN](https://tsp.esta.org/tsp/documents/docs/ANSI_E1-31-2018.pdf), and [DDP](http://www.3waylabs.com/ddp/) over the network — interoperable with Falcon, Advatek, xLights, Resolume, LedFx and other industry gear. + +🎵 **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. + +🛡️ **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. + +🖥️ **One source tree, many targets**: the same code runs on ESP32, Teensy, Raspberry Pi, and macOS / Windows / Linux. + +🎨 **Plug in, open a browser, see lights**: a live 3D preview of every effect, modifier, and layout, controllable from the same tab. The interface renders any module from its declared controls, so adding a module needs zero UI code. + +⚡ **Flash from your browser in seconds**: the web installer picks your board, flashes the matching firmware, and hands WiFi credentials to the device over USB via Improv. No serial monitor, no recompile. ## Under the hood -🛠️ **ESP-IDF directly, no Arduino** — the ESP32 build is pure ESP-IDF (v6.x): native LED drivers, `esp_http_server`, FreeRTOS, built with `idf.py`, not PlatformIO or the Arduino framework. See [building.md § Why not Arduino](docs/building.md#why-not-arduino). +🛠️ **ESP-IDF directly, no Arduino**: the ESP32 build is pure ESP-IDF (v6.x): native LED drivers, `esp_http_server`, FreeRTOS, built with `idf.py`, not PlatformIO or the Arduino framework. See [building.md § Why not Arduino](docs/building.md#why-not-arduino). + +📦 **No third-party libraries**: no FastLED, no ESPAsyncWebServer, no ArduinoJson. The colour math, the HTTP/WebSocket server, and the control storage are all in-tree. A library, when genuinely needed, lives behind the platform boundary in `src/platform/`, never in core. The full rationale + replacements: [building.md § Third-party libraries](docs/building.md#third-party-libraries). -📦 **No third-party libraries** — no FastLED, no ESPAsyncWebServer, no ArduinoJson. The colour math, the HTTP/WebSocket server, and the control storage are all in-tree. A library, when genuinely needed, lives behind the platform boundary in `src/platform/`, never in core. The full rationale + replacements: [building.md § Third-party libraries](docs/building.md#third-party-libraries). +🔬 **Industry standards, our own code**: we study the prior art hard — friend repos, peripheral datasheets, the Art-Net / E1.31 / WS2812 standards — carry its *ideas* forward, and credit it by name; but we write our own code rather than copying theirs or tracing their structure. Each feature is spec'd from the primary source, its behaviour pinned with unit + scenario tests, then written fresh against our own architecture, so the result is independent by construction, not a renamed fork. Textbook algorithm, textbook name, our implementation. The method: [CLAUDE.md § Principles](CLAUDE.md#principles). -🧱 **One module model** — every effect, modifier, layout, and driver is a `MoonModule`: one base class, a uniform lifecycle, declared controls. That uniformity is why the UI renders any module with zero per-module code, and why a new capability is a new file, not a new framework. See [architecture.md § MoonModules](docs/architecture.md#moonmodules). +🧱 **One module model**: every effect, modifier, layout, and driver is a `MoonModule`: one base class, a uniform lifecycle, declared controls. That uniformity is why the UI renders any module with zero per-module code, and why a new capability is a new file, not a new framework. See [architecture.md § MoonModules](docs/architecture.md#moonmodules). ## Performance Measured end-to-end through a full render pipeline (effect → modifier → ArtNet output) on real hardware. FPS is derived from the per-frame tick time. -The **Desktop** column is host-CPU-bound, not OS-bound — the numbers track the machine, not macOS vs Windows vs Linux. Captured on Apple Silicon (M-series); the macOS and Windows binaries run the same code on comparable hardware. +The **Desktop** column is host-CPU-bound, not OS-bound: the numbers track the machine, not macOS vs Windows vs Linux. Captured on Apple Silicon (M-series); the macOS and Windows binaries run the same code on comparable hardware. ### Frames per second | Grid | Lights | Desktop | Olimex `esp32-eth-wifi` | Olimex `esp32-eth` | LOLIN S3 N16R8 `esp32s3-n16r8` | |---|---:|---:|---:|---:|---:| -| 16×16 | 256 | — *(below host clock resolution)* | 1,543 | 1,628 | 1,672 | +| 16×16 | 256 | *(below host clock resolution)* | 1,543 | 1,628 | 1,672 | | 32×32 | 1,024 | 166,667 | 447 | 432 | 287 | | 64×64 | 4,096 | 40,000 | 81 | 71 | 25 | | 128×128 | 16,384 | 9,708 | 11 | 10 | 6 | -The LOLIN S3 N16R8 is WiFi-only and runs with `Network.txPowerSetting` capped to 8 dBm (the brown-out fix — see below). At 128×128 it's bound by ArtNet over WiFi at reduced TX power (~93 ms of the ~164 ms tick), which is why it trails the Ethernet boards despite a faster core. The board's niche is PSRAM headroom (8 MB) for large pixel buffers, not raw ArtNet FPS — use an Ethernet board when frame rate matters. +The LOLIN S3 N16R8 is WiFi-only and runs with `Network.txPowerSetting` capped to 8 dBm (the brown-out fix, see below). At 128×128 it's bound by ArtNet over WiFi at reduced TX power (~93 ms of the ~164 ms tick), which is why it trails the Ethernet boards despite a faster core. The board's niche is PSRAM headroom (8 MB) for large pixel buffers, not raw ArtNet FPS; use an Ethernet board when frame rate matters. ### Free heap -Each cell is **free internal RAM / largest contiguous internal-RAM block**. Internal RAM is the scarce, comparable resource across all boards — so for PSRAM boards (the S3) this is internal-only, NOT the PSRAM-merged total (we assume the 8 MB PSRAM pool is large enough that it isn't the constraint). The block size is the memory-pressure signal that matters: free RAM can be ample while fragmentation leaves no single block big enough for the next allocation. +Each cell is **free internal RAM / largest contiguous internal-RAM block**. Internal RAM is the scarce, comparable resource across all boards, so for PSRAM boards (the S3) this is internal-only, NOT the PSRAM-merged total (we assume the 8 MB PSRAM pool is large enough that it isn't the constraint). The block size is the memory-pressure signal that matters: free RAM can be ample while fragmentation leaves no single block big enough for the next allocation. | Grid | Desktop | Olimex `esp32-eth-wifi` | Olimex `esp32-eth` | LOLIN S3 N16R8 `esp32s3-n16r8` | |---|---:|---:|---:|---:| @@ -66,34 +78,34 @@ Each cell is **free internal RAM / largest contiguous internal-RAM block**. Inte | 64×64 | unlimited | 108 KB / 48 KB | 147 KB / 62 KB | 236 KB / 152 KB | | 128×128 | unlimited | 129 KB / 52 KB | 132 KB / 48 KB | 240 KB / 164 KB | -The S3's internal-free stays flat across grid sizes because its Layer buffer + LUT live in PSRAM — growing the grid consumes PSRAM, not internal RAM. The Olimex boards hold those buffers in internal RAM, so their free heap drops as the grid grows. +The S3's internal-free stays flat across grid sizes because its Layer buffer + LUT live in PSRAM: growing the grid consumes PSRAM, not internal RAM. The Olimex boards hold those buffers in internal RAM, so their free heap drops as the grid grows. Build variants differ structurally: `esp32-eth-wifi` includes the WiFi stack (~270 KB flash, ~28 KB heap). `esp32-eth` drops WiFi for more free heap, at the cost of slightly slower tick on large grids (lwIP buffer-pool sizing is tuned for the eth-wifi sdkconfig). The right variant depends on whether the deployment needs WiFi, Ethernet, or large buffers. -The numbers above are observations. The **contracts** projectMM commits to — what the device must hit on every CI run — live in [`test/scenarios/*.json`](test/scenarios/) as per-step `contract.` blocks; see [docs/testing.md § Performance contracts](docs/testing.md#performance-contracts-contracttarget) for how they're set and renegotiated. The [docs/performance.md](docs/performance.md) page covers the *why* (WiFi vs Ethernet physics, sizeof tables, build-variant deltas). +The numbers above are observations. The **contracts** projectMM commits to, what the device must hit on every CI run, live in [`test/scenarios/*.json`](test/scenarios/) as per-step `contract.` blocks; see [docs/testing.md § Performance contracts](docs/testing.md#performance-contracts-contracttarget) for how they're set and renegotiated. The [docs/performance.md](docs/performance.md) page covers the *why* (WiFi vs Ethernet physics, sizeof tables, build-variant deltas). ## Getting started ### From a release -**ESP32 — flash from your browser.** Open the [web installer](https://moonmodules.org/projectMM/install/) in Chrome or Edge — it walks you through release, board and firmware selection, flashing, and network setup. The installer lists stable releases and a `latest` build (published automatically on every merge to main) carrying the newest unreleased changes. +**ESP32: flash from your browser.** Open the [web installer](https://moonmodules.org/projectMM/install/) in Chrome or Edge; it walks you through release, board and firmware selection, flashing, and network setup. The installer lists stable releases and a `latest` build (published automatically on every merge to main) carrying the newest unreleased changes. ![Installer](docs/assets/screenshots/installer.png) -**Desktop — download and run.** Grab the build for your OS from the [releases page](https://github.com/MoonModules/projectMM/releases): +**Desktop: download and run.** Grab the build for your OS from the [releases page](https://github.com/MoonModules/projectMM/releases): -- **macOS arm64:** `projectMM-macos-arm64-vX.Y.Z.tar.gz` — unpack, run `./projectMM`. The binary is unsigned, so Gatekeeper prompts on first run — right-click → Open, or clear the quarantine flag with `xattr -dr com.apple.quarantine ./projectMM`. -- **Windows x64:** `projectMM-windows-x64-vX.Y.Z.zip` — unzip, double-click `projectMM.exe`. SmartScreen may warn on first run because the binary is unsigned (More info → Run anyway). +- **macOS arm64:** `projectMM-macos-arm64-vX.Y.Z.tar.gz`: unpack, run `./projectMM`. The binary is unsigned, so Gatekeeper prompts on first run; right-click → Open, or clear the quarantine flag with `xattr -dr com.apple.quarantine ./projectMM`. +- **Windows x64:** `projectMM-windows-x64-vX.Y.Z.zip`: unzip, double-click `projectMM.exe`. SmartScreen may warn on first run because the binary is unsigned (More info → Run anyway). Then open `http://localhost:8080/`. -Once running, the UI lets you build a render pipeline visually (layouts → layers with effects + modifiers → drivers), preview the result in 3D, send it to Art-Net, and save it. The source tree also builds for Teensy, Raspberry Pi, and Linux from source — see [building.md](docs/building.md) — though currently only the macOS, Windows, and ESP32 binaries ship as releases. +Once running, the UI lets you build a render pipeline visually (layouts → layers with effects + modifiers → drivers), preview the result in 3D, send it to Art-Net, and save it. The source tree also builds for Teensy, Raspberry Pi, and Linux from source (see [building.md](docs/building.md)), though currently only the macOS, Windows, and ESP32 binaries ship as releases. ### From source -You need [uv](https://docs.astral.sh/uv/) (Python launcher), CMake 3.20+, and a C++20 compiler. For ESP32, ESP-IDF v6.x is also required — see [building.md](docs/building.md) for the full setup instructions. +You need [uv](https://docs.astral.sh/uv/) (Python launcher), CMake 3.20+, and a C++20 compiler. For ESP32, ESP-IDF v6.x is also required; see [building.md](docs/building.md) for the full setup instructions. -Once prerequisites are in place, launch MoonDeck — the browser-based dev console: +Once prerequisites are in place, launch MoonDeck, the browser-based dev console: ```sh uv run scripts/moondeck.py @@ -107,27 +119,27 @@ Open `http://localhost:8420`: PC tab to build / run / test, ESP32 tab to flash, | Document | What's in it | |----------|--------------| -| [architecture.md](docs/architecture.md) | How the system is put together — core runtime + light domain, pipeline, memory, parallelism | -| [coding-standards.md](docs/coding-standards.md) | How code in this repo is written — conventions, file shape, static checks | +| [architecture.md](docs/architecture.md) | How the system is put together: core runtime + light domain, pipeline, memory, parallelism | +| [coding-standards.md](docs/coding-standards.md) | How code in this repo is written: conventions, file shape, static checks | | [building.md](docs/building.md) | How to build and flash for every supported target | | [testing.md](docs/testing.md) | What tests exist and what they cover | -| [performance.md](docs/performance.md) | Per-module timing, memory, sizeof — per platform | -| [moonmodules/](docs/moonmodules/) | One spec page per module — [core](docs/moonmodules/core/) services and [light](docs/moonmodules/light/) effects, layouts, modifiers, drivers | +| [performance.md](docs/performance.md) | Per-module timing, memory, sizeof, per platform | +| [moonmodules/](docs/moonmodules/) | One spec page per module: [core](docs/moonmodules/core/) services and [light](docs/moonmodules/light/) effects, layouts, modifiers, drivers | | [CLAUDE.md](CLAUDE.md) | Rules, constraints, and development process | ## How we work -projectMM is built by AI agents under tight human direction. Everything in this repository — firmware and desktop code, the web installer, the MoonDeck dev console, all documentation, the unit and scenario tests, even the UI screenshots and effect GIFs — is authored by agents; the **product owner** writes none of it directly. What the product owner *does* author is the **process** ([CLAUDE.md](CLAUDE.md)), the **architecture** ([architecture.md](docs/architecture.md)), and the **module specifications** ([docs/moonmodules/](docs/moonmodules/)) — then decides what to build next, reviews every line and every spec, runs the hardware tests, and controls every commit, merge, and release. Agents write in defined roles; they don't make decisions. The agent writes; the product owner thinks. +projectMM is built by AI agents under tight human direction. Everything in this repository, firmware and desktop code, the web installer, the MoonDeck dev console, all documentation, the unit and scenario tests, even the UI screenshots and effect GIFs, is authored by agents; the **product owner** writes none of it directly. What the product owner *does* author is the **process** ([CLAUDE.md](CLAUDE.md)), the **architecture** ([architecture.md](docs/architecture.md)), and the **module specifications** ([docs/moonmodules/](docs/moonmodules/)); then decides what to build next, reviews every line and every spec, runs the hardware tests, and controls every commit, merge, and release. Agents write in defined roles; they don't make decisions. The agent writes; the product owner thinks. Meet the team: 🤖 Architect designs, 👽 Developer implements, 👾 Reviewer checks before merge, 🛸 Tester verifies, 💀 Runner does quick build and check passes. Full team descriptions in [CLAUDE.md](CLAUDE.md). A few principles run through everything: -- **Common patterns first** — recognisable practice across code, docs, tests, UI. Bespoke choices need a stated reason. -- **Specs before code** — a module is documented in [`docs/moonmodules/`](docs/moonmodules/) — purpose, controls, behaviour, edge cases, prior art — well enough to implement from before it's written. -- **Working software at every commit** — each commit builds, passes the test + scenario gates, and produces something you can see run; never a broken intermediate state. -- **Minimalism** — flat, predictable code; removing code beats adding it; every addition pays for itself. -- **The system as it is** — code and docs describe the present; git history is the changelog. +- **Common patterns first**: recognisable practice across code, docs, tests, UI. Bespoke choices need a stated reason. +- **Specs before code**: a module is documented in [`docs/moonmodules/`](docs/moonmodules/), purpose, controls, behaviour, edge cases, prior art, well enough to implement from before it's written. +- **Working software at every commit**: each commit builds, passes the test + scenario gates, and produces something you can see run; never a broken intermediate state. +- **Minimalism**: flat, predictable code; removing code beats adding it; every addition pays for itself. +- **The system as it is**: code and docs describe the present; git history is the changelog. The full rules and process are in [CLAUDE.md](CLAUDE.md). @@ -142,16 +154,16 @@ This is the current iteration of years of LED / light system development. Each p | **StarLight** | Standalone LED firmware | [ewowi/StarLight](https://github.com/ewowi/StarLight) | | **MoonLight** | Ground-up build: 60+ effects, memory-optimised mapping, 11 driver types | [MoonModules/MoonLight](https://github.com/MoonModules/MoonLight) | -We built, maintained, and contributed to these projects — so projectMM is grounded in years of our own hands-on experience, not arms-length study. Their lessons and proven patterns are distilled in [`docs/history/`](docs/history/README.md), alongside monthly digests of friend projects (like FastLED and upstream WLED) we follow closely but don't own. From all of it we carry the ideas forward into our own implementation: we apply what we learned and write our own code, never copying theirs — and when a specific project or person inspires something here, we credit them by name (in the history digests and each module's "Prior art" notes). +We built, maintained, and contributed to these projects, so projectMM is grounded in years of our own hands-on experience, not arms-length study. Their lessons and proven patterns are distilled in [`docs/history/`](docs/history/README.md), alongside monthly digests of friend projects (like FastLED and upstream WLED) we follow closely but don't own. From all of it we carry the ideas forward into our own implementation: we apply what we learned and write our own code rather than copying theirs; and when a specific project or person inspires something here, we credit them by name (in the history digests and each module's "Prior art" notes). ## Contributing -projectMM is a community project — built in the open, shaped by the people who use it. We'd love to hear from you: +projectMM is a community project, built in the open, shaped by the people who use it. We'd love to hear from you: -- **Ideas and requests** — an effect, a layout, a driver, a fixture you want supported? [Open an issue](https://github.com/MoonModules/projectMM/issues) and tell us. -- **Help build it** — pick something from the [issues](https://github.com/MoonModules/projectMM/issues), or propose a MoonModule. See [How we work](#how-we-work) for the process. -- **Test on hardware** — run it on your panels, boards, and fixtures, and report what works and what doesn't. -- **Talk to us** — questions, show-and-tell, and design discussion on [Discord](https://discord.gg/TC8NSUSCdV). +- **Ideas and requests**: an effect, a layout, a driver, a fixture you want supported? [Open an issue](https://github.com/MoonModules/projectMM/issues) and tell us. +- **Help build it**: pick something from the [issues](https://github.com/MoonModules/projectMM/issues), or propose a MoonModule. See [How we work](#how-we-work) for the process. +- **Test on hardware**: run it on your panels, boards, and fixtures, and report what works and what doesn't. +- **Talk to us**: questions, show-and-tell, and design discussion on [Discord](https://discord.gg/TC8NSUSCdV). Find the MoonModules community on [Discord](https://discord.gg/TC8NSUSCdV), [Reddit](https://reddit.com/r/moonmodules), [YouTube](https://www.youtube.com/@MoonModulesLighting), and [GitHub](https://github.com/MoonModules). diff --git a/docs/architecture.md b/docs/architecture.md index 80f8040b..83e31104 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -This document is the agreed-up-front **architecture contract** — what projectMM is designed to be. Most of it describes the system as it is today; a few load-bearing design decisions are settled but not yet implemented. Those are marked **🚧 — designed, not implemented yet (high priority for release 2)**. The 🚧 marker means the design is committed (this is how it *will* work, and code should be written toward it), not that it's optional or undecided. Anything without the marker is live today. +This document is the agreed-up-front **architecture contract**: what projectMM is designed to be. Most of it describes the system as it is today; a few load-bearing design decisions are settled but not yet implemented. Those are marked **🚧: designed, not implemented yet (high priority for release 2)**. The 🚧 marker means the design is committed (this is how it *will* work, and code should be written toward it), not that it's optional or undecided. Anything without the marker is live today. Coding conventions live in [coding-standards.md](coding-standards.md); how to build and run lives in [building.md](building.md); what is tested lives in [testing.md](testing.md). @@ -18,6 +18,7 @@ Coding conventions live in [coding-standards.md](coding-standards.md); how to bu - [Parallelism](#parallelism) - [Data exchange between modules](#data-exchange-between-modules) - [Event triggering between modules](#event-triggering-between-modules) + - [Live reconfiguration: every change applies without a reboot](#live-reconfiguration-every-change-applies-without-a-reboot) - [Robustness](#robustness) - [Hot path discipline](#hot-path-discipline) - [Platform abstraction](#platform-abstraction) @@ -50,21 +51,21 @@ Coding conventions live in [coding-standards.md](coding-standards.md); how to bu Build a modular runtime for resource-constrained embedded devices that the same source compiles for, unmodified, on ESP32, Teensy, desktop, and Raspberry Pi. The runtime must: -- Compose behaviour from small, uniform units (modules) that can be created, configured, reordered, and removed at runtime — including from a network API. +- Compose behaviour from small, uniform units (modules) that can be created, configured, reordered, and removed at runtime, including from a network API. - Expose every module's parameters generically so a single web UI renders any module with zero per-module UI code. - Run a hot loop with predictable timing and zero steady-state heap allocation on devices with as little as ~320 KB of RAM. - Persist configuration across reboots, exploit multiple CPU cores where present, and keep all platform-specific code behind one boundary. -The first concrete use of this runtime is lighting: drive 10,000+ addressable LEDs and DMX fixtures (RGB(W) pars, moving heads, dimmers) across multiple synchronised devices at high frame rates. The runtime is general enough that other real-time domains — audio synthesis, motor control — could be layered on the same way; lighting is the only domain implemented today. +The first concrete use of this runtime is lighting: drive 10,000+ addressable LEDs and DMX fixtures (RGB(W) pars, moving heads, dimmers) across multiple synchronised devices at high frame rates. The runtime is general enough that other real-time domains (audio synthesis, motor control) could be layered on the same way; lighting is the only domain implemented today. ## Core and light domain The system is two layers, separated as much as practical: -- **Core** — MoonModule base, controls, scheduling, persistence, platform abstraction, system services (HTTP, WiFi, filesystem). Domain-neutral. Knows nothing about lights. -- **Light domain** — light values, layouts, layers, mapping, blending, effects, modifiers, LED drivers, ArtNet/DDP. Built on top of the core. +- **Core**: MoonModule base, controls, scheduling, persistence, platform abstraction, system services (HTTP, WiFi, filesystem). Domain-neutral. Knows nothing about lights. +- **Light domain**: light values, layouts, layers, mapping, blending, effects, modifiers, LED drivers, ArtNet/DDP. Built on top of the core. -When mixing is needed (for performance or simplicity), it must be an explicit decision — consciously choosing minimalism over separation, not accidentally blurring the boundary. Use domain-neutral naming in those cases ("producer buffer" not "LED buffer", "output driver" not "LED driver" in core interfaces) to keep the door open for future separation. +When mixing is needed (for performance or simplicity), it must be an explicit decision: consciously choosing minimalism over separation, not accidentally blurring the boundary. Use domain-neutral naming in those cases ("producer buffer" not "LED buffer", "output driver" not "LED driver" in core interfaces) to keep the door open for future separation. # Core @@ -72,14 +73,14 @@ The core's job is the runtime: modules, their lifecycle, their parameters, how t ## MoonModules -The core building block is a **[MoonModule](moonmodules/core/MoonModule.md)**. Everything is a MoonModule — not just effects, modifiers, layouts, and drivers, but also system services (HTTP server, WebSocket server, file server, WiFi, mDNS, OTA updates) and [peripherals](#peripherals) (sensors and actuators bridging to hardware/network). The core itself is minimal: MoonModule base, buffer management, a [Scheduler](moonmodules/core/Scheduler.md). +The core building block is a **[MoonModule](moonmodules/core/MoonModule.md)**. Everything is a MoonModule, not just effects, modifiers, layouts, and drivers, but also system services (HTTP server, WebSocket server, file server, WiFi, mDNS, OTA updates) and [peripherals](#peripherals) (sensors and actuators bridging to hardware/network). The core itself is minimal: MoonModule base, buffer management, a [Scheduler](moonmodules/core/Scheduler.md). This means: - Every MoonModule shares the same class structure, lifecycle (`setup`, `loop`, `teardown`), and controls. Learn the pattern once, apply it everywhere. -- System services get controls for free — HTTP port, WiFi SSID, mDNS hostname are all configurable through the same UI as effect parameters. -- Capabilities are modular — no WiFi? don't load the WiFi MoonModule. No `#ifdef`s needed. -- System MoonModules that listen (HTTP, WebSocket) poll in their `loop()` — the standard pattern for embedded servers. +- System services get controls for free: HTTP port, WiFi SSID, mDNS hostname are all configurable through the same UI as effect parameters. +- Capabilities are modular: no WiFi? don't load the WiFi MoonModule. No `#ifdef`s needed. +- System MoonModules that listen (HTTP, WebSocket) poll in their `loop()`, the standard pattern for embedded servers. - The scheduler handles init-order dependencies between system MoonModules (e.g. WiFi before HTTP, HTTP before WebSocket). Modules can be added, replaced, reordered, or removed at runtime. On removal (teardown), all allocated resources are cleaned up. @@ -88,11 +89,11 @@ Modules can be added, replaced, reordered, or removed at runtime. On removal (te A MoonModule that owns children gets the standard lifecycle methods propagated to them automatically: -- `setup()` and `teardown()` — chain into children. Teardown reverse-iterates so children clean up before the parent does. -- `loop()`, `loop20ms()`, `loop1s()` — tick each child gated by the same rule the Scheduler applies to top-level modules (`!respectsEnabled() || enabled()` — modules that opted out of the enabled gate keep ticking, the rest tick only when enabled), with per-child timing accumulated into the child's own `loopTimeUs()`. -- `onBuildControls()` and `onBuildState()` — chain into children. +- `setup()` and `teardown()`: chain into children. Teardown reverse-iterates so children clean up before the parent does. +- `loop()`, `loop20ms()`, `loop1s()`: tick each child gated by the same rule the Scheduler applies to top-level modules (`!respectsEnabled() || enabled()`, where modules that opted out of the enabled gate keep ticking, the rest tick only when enabled), with per-child timing accumulated into the child's own `loopTimeUs()`. +- `onBuildControls()` and `onBuildState()`: chain into children. -This means a container module gets correct lifecycle handling for its children without writing the iteration itself. Leaf modules (no children) pay one predicted-not-taken branch per call — sub-nanosecond. When a container overrides one of these methods to add its own work, the chain-to-base convention (parent-before vs child-before per callback) lives in [coding-standards.md § Override-and-chain convention](coding-standards.md#override-and-chain-convention). +This means a container module gets correct lifecycle handling for its children without writing the iteration itself. Leaf modules (no children) pay one predicted-not-taken branch per call, sub-nanosecond. When a container overrides one of these methods to add its own work, the chain-to-base convention (parent-before vs child-before per callback) lives in [coding-standards.md § Override-and-chain convention](coding-standards.md#override-and-chain-convention). **ModuleFactory** is a static registry mapping type names (strings) to create functions. The HTTP API uses it to create modules at runtime (`POST /api/modules {"type":"NoiseEffect"}`); the main pipeline in `main.cpp` constructs modules directly. Registration captures `sizeof(T)` for memory reporting: @@ -102,17 +103,17 @@ ModuleFactory::registerType("NoiseEffect"); ModuleFactory is core infrastructure ([`src/core/ModuleFactory.h`](../src/core/ModuleFactory.h)), not itself a MoonModule. -**Dynamic over fixed-size.** Children, module lists, control sets — anything structural — grow on demand from the heap during `setup()`. Fixed-size arrays impose arbitrary limits, waste memory on instances that don't use the full capacity, and cost memory on instances that need none (e.g. leaf modules with zero children). The hot path only iterates these arrays — same pointer arithmetic as a fixed array, no performance difference. +**Dynamic over fixed-size.** Children, module lists, control sets, anything structural, grow on demand from the heap during `setup()`. Fixed-size arrays impose arbitrary limits, waste memory on instances that don't use the full capacity, and cost memory on instances that need none (e.g. leaf modules with zero children). The hot path only iterates these arrays: same pointer arithmetic as a fixed array, no performance difference. -**Self-reporting.** Every MoonModule reports its own footprint and cost: `classSize()` (the `sizeof` of the class instance, captured at registration), `dynamicBytes()` (heap allocated during `onBuildState`), and `loopTimeUs()` (average time its `loop` took, accumulated per tick). These surface in `/api/system`, console output, and scenario tests — the same numbers for an effect, a driver, or a system service, because they're a base-class feature, not a light-domain one. +**Self-reporting.** Every MoonModule reports its own footprint and cost: `classSize()` (the `sizeof` of the class instance, captured at registration), `dynamicBytes()` (heap allocated during `onBuildState`), and `loopTimeUs()` (average time its `loop` took, accumulated per tick). These surface in `/api/system`, console output, and scenario tests: the same numbers for an effect, a driver, or a system service, because they're a base-class feature, not a light-domain one. Each MoonModule is documented in [`docs/moonmodules/`](moonmodules/) as it is built. ## Controls -Every MoonModule exposes **[controls](moonmodules/core/Control.md)** — runtime-configurable parameters visible in the web UI. A grid layout exposes width, height, depth. An ArtNet driver exposes destination IP and universe. A fire effect exposes speed, cooling, sparking. +Every MoonModule exposes **[controls](moonmodules/core/Control.md)**: runtime-configurable parameters visible in the web UI. A grid layout exposes width, height, depth. An ArtNet driver exposes destination IP and universe. A fire effect exposes speed, cooling, sparking. -Controls bind to MoonModule member variables. The variable's default is the control's default. The hot path reads the variable directly — no function call. When a control value changes, the system notifies the owning MoonModule for cold-path reactions — recompute a derived table, re-size a buffer, re-bind a socket (the three-tier mechanism is [§ Event triggering between modules](#event-triggering-between-modules)). +Controls bind to MoonModule member variables. The variable's default is the control's default. The hot path reads the variable directly, no function call. When a control value changes, the system notifies the owning MoonModule for cold-path reactions: recompute a derived table, re-size a buffer, re-bind a socket (the three-tier mechanism is [§ Event triggering between modules](#event-triggering-between-modules)). Controls are dynamic: when a value changes, the control set can be rebuilt. A select control that picks a mode can show/hide other controls based on the choice. @@ -124,11 +125,11 @@ Controls are the bridge between the [web UI](moonmodules/core/ui.md) and the run Control values and each module's `enabled` flag are persisted to flash so settings survive a reboot. The mechanism lives in [FilesystemModule](moonmodules/core/FilesystemModule.md): -- **Storage** — one flat JSON file per top-level module under `/.config/.json`. Children are encoded positionally with `.` key prefixes — no nested objects, no arrays. The parser stays minimal (three flat-JSON helpers in `core/JsonUtil.h`). -- **Lifecycle** — `Scheduler::setup()` runs four phases: (1) `onBuildControls` binds every module's full control set, (2) the FilesystemModule load hook overlays persisted values onto the bound variables, (2b) `rebuildControls` re-evaluates conditional `hidden` flags against the loaded state, (3) each module's own `setup()` runs with persisted values already in member variables, (4) `onBuildState` sizes buffers. Modules themselves know nothing about persistence — they just bind their variables. -- **Save trigger** — HttpServerModule marks the target module dirty on every successful control mutation. FilesystemModule debounces 2 s in `loop1s()`, walks the tree, writes any subtree containing a dirty descendant via atomic write-and-rename. -- **Conditional controls** — every conditional control is always bound; the module sets a `hidden` flag (`controls_.setHidden(i, …)`) to tell the UI not to render it. The load path can therefore find persisted values regardless of the live conditional state. -- **Code-wired children survive a stale file** — some children aren't created by the user; `main.cpp`'s boot wiring attaches them (`ImprovProvisioningModule` under `NetworkModule`; `BoardModule`, `ArtNetSendDriver`, `PreviewDriver` under their parents). Each such child calls `markWiredByCode()` after `addChild()` — a one-bit flag meaning *"I belong here because the code put me here, not because a saved file or a user asked for me."* The problem it solves: persistence reconciles the live tree to match the saved JSON, so a child that exists in code but is absent from an older saved file (written before that child was added) would be trimmed on load. The flag tells the apply step to keep it. Children added through the HTTP API or recreated from JSON stay unmarked — those follow the file's tree shape exactly, so UI deletes still take effect. +- **Storage**: one flat JSON file per top-level module under `/.config/.json`. Children are encoded positionally with `.` key prefixes, with no nested objects, no arrays. The parser stays minimal (three flat-JSON helpers in `core/JsonUtil.h`). +- **Lifecycle**: `Scheduler::setup()` runs four phases: (1) `onBuildControls` binds every module's full control set, (2) the FilesystemModule load hook overlays persisted values onto the bound variables, (2b) `rebuildControls` re-evaluates conditional `hidden` flags against the loaded state, (3) each module's own `setup()` runs with persisted values already in member variables, (4) `onBuildState` sizes buffers. Modules themselves know nothing about persistence; they just bind their variables. +- **Save trigger**: HttpServerModule marks the target module dirty on every successful control mutation. FilesystemModule debounces 2 s in `loop1s()`, walks the tree, writes any subtree containing a dirty descendant via atomic write-and-rename. +- **Conditional controls**: every conditional control is always bound; the module sets a `hidden` flag (`controls_.setHidden(i, …)`) to tell the UI not to render it. The load path can therefore find persisted values regardless of the live conditional state. +- **Code-wired children survive a stale file**: some children aren't created by the user; `main.cpp`'s boot wiring attaches them (`ImprovProvisioningModule` under `NetworkModule`; `BoardModule`, `NetworkSendDriver`, `PreviewDriver` under their parents). Each such child calls `markWiredByCode()` after `addChild()`, a one-bit flag meaning *"I belong here because the code put me here, not because a saved file or a user asked for me."* The problem it solves: persistence reconciles the live tree to match the saved JSON, so a child that exists in code but is absent from an older saved file (written before that child was added) would be trimmed on load. The flag tells the apply step to keep it. Children added through the HTTP API or recreated from JSON stay unmarked; those follow the file's tree shape exactly, so UI deletes still take effect. How persistence reaches the Scheduler without the Scheduler depending on it: the Scheduler exposes a **function-pointer hook** (`setLoadAllHook`) that the load phase calls if set. FilesystemModule registers its load routine through that hook at startup; the Scheduler never includes or names FilesystemModule (no circular dependency, and persistence is fully optional). With no FilesystemModule registered the hook is null, the load phase is a no-op, and the system runs with member-initialised defaults. @@ -136,11 +137,11 @@ How persistence reaches the Scheduler without the Scheduler depending on it: the On multi-core systems (ESP32 has 2 cores, desktop / RPi have many), the system exploits parallelism by assigning MoonModules to specific cores. Each MoonModule can declare a core affinity. The scheduler respects this when pinning tasks. On single-core or desktop systems, affinity is ignored and everything runs on available threads. -The model is **producers vs consumers**: producers generate data, consumers process and output it. The light domain instantiates it concretely — effects are producers, drivers are consumers. +The model is **producers vs consumers**: producers generate data, consumers process and output it. The light domain instantiates it concretely: effects are producers, drivers are consumers. -Today the pipeline runs **single-threaded**: `Scheduler::tick()` runs the producers (effects), then the blend+map, then the consumers (drivers) in sequence on one core. With one thread there's no concurrent access and therefore no lock — the consumer always reads a fully-written frame because the producer already finished this tick. +Today the pipeline runs **single-threaded**: `Scheduler::tick()` runs the producers (effects), then the blend+map, then the consumers (drivers) in sequence on one core. With one thread there's no concurrent access and therefore no lock: the consumer always reads a fully-written frame because the producer already finished this tick. -**🚧 Two-core double-buffer handover.** The committed design runs producer and consumer on **separate cores** so the consumer transmits frame N while the producer fills frame N+1 — the throughput win the single-thread loop leaves on the table. The hand-off is an **atomic double-buffer swap**, not a held lock: the producer fills buffer A; when done it atomically hands A to the consumer and starts filling B; the consumer copies/encodes A out (to its DMA buffer or socket) and, when finished, signals the producer that A is free to fill again. Neither side ever reads or writes the buffer the other currently owns, so the synchronisation is a single atomic flag/pointer swap rather than a lock around the whole frame. This matters because the consumer's copy-out (LED DMA encode, ArtNet packetise) is slow relative to a buffer fill — without the swap the producer would lap the consumer and the consumer would read a half-written frame (a visible glitch). Which buffers play the double-buffer role is covered in [§ Memory strategy](#memory-strategy). +**🚧 Two-core double-buffer handover.** The committed design runs producer and consumer on **separate cores** so the consumer transmits frame N while the producer fills frame N+1: the throughput win the single-thread loop leaves on the table. The hand-off is an **atomic double-buffer swap**, not a held lock: the producer fills buffer A; when done it atomically hands A to the consumer and starts filling B; the consumer copies/encodes A out (to its DMA buffer or socket) and, when finished, signals the producer that A is free to fill again. Neither side ever reads or writes the buffer the other currently owns, so the synchronisation is a single atomic flag/pointer swap rather than a lock around the whole frame. This matters because the consumer's copy-out (LED DMA encode, ArtNet packetise) is slow relative to a buffer fill: without the swap the producer would lap the consumer and the consumer would read a half-written frame (a visible glitch). Which buffers play the double-buffer role is covered in [§ Memory strategy](#memory-strategy). ## Data exchange between modules @@ -153,48 +154,52 @@ When one module produces data another module reads on the hot path, the pattern - The producer exposes the struct via a `const`-returning getter (or a `setX(const Foo*)` setter on the consumer). - The **consumer holds a `const Foo*`** received once at wiring time in `main.cpp`, and reads it on the hot path each frame. -No registry, no subscription, no event bus. The consumer reads the latest value when it needs it; if the producer wrote nothing this tick, the consumer sees the previous value (acceptable for the kinds of data this exchanges — small state structs, periodic captures). This pull pattern is lock-free **for a small POD struct overwritten in place**: a reader on another core might catch a half-updated struct, but the result is one slightly-inconsistent read of a few fields that self-corrects next tick — visually harmless for the gyro/sensor data this carries, and cheaper than a lock. That tolerance does **not** extend to a large frame buffer the consumer copies out wholesale (an LED DMA buffer, an ArtNet packet) — there a half-written read is a visible glitch, so that hand-off uses the 🚧 two-core double-buffer swap from [§ Parallelism](#parallelism), not this lock-free pull. +No registry, no subscription, no event bus. The consumer reads the latest value when it needs it; if the producer wrote nothing this tick, the consumer sees the previous value (acceptable for the kinds of data this exchanges: small state structs, periodic captures). This pull pattern is lock-free **for a small POD struct overwritten in place**: a reader on another core might catch a half-updated struct, but the result is one slightly-inconsistent read of a few fields that self-corrects next tick, visually harmless for the gyro/sensor data this carries, and cheaper than a lock. That tolerance does **not** extend to a large frame buffer the consumer copies out wholesale (an LED DMA buffer, an ArtNet packet): there a half-written read is a visible glitch, so that hand-off uses the 🚧 two-core double-buffer swap from [§ Parallelism](#parallelism), not this lock-free pull. -**Push through a domain-neutral sink.** When the producer should hand bytes to a generic core service rather than expose a struct, the core defines a narrow interface and the producer pushes to it. The producer owns the data and its wire format; the core sink (the interface's implementer) knows only "take these bytes and do my generic job" — it has zero knowledge of what the bytes mean or which domain produced them. `BinaryBroadcaster` (`HttpServerModule` implements it: "broadcast these bytes to all WebSocket clients") is the example — the producer side lives in the light domain (see [§ The pipeline](#the-pipeline)). +**Push through a domain-neutral sink.** When the producer should hand bytes to a generic core service rather than expose a struct, the core defines a narrow interface and the producer pushes to it. The producer owns the data and its wire format; the core sink (the interface's implementer) knows only "take these bytes and do my generic job"; it has zero knowledge of what the bytes mean or which domain produced them. `BinaryBroadcaster` (`HttpServerModule` implements it: "broadcast these bytes to all WebSocket clients") is the example; the producer side lives in the light domain (see [§ The pipeline](#the-pipeline)). -Both shapes extend without ceremony to any future producer/consumer pair (a sensor module owning a state struct, an effect reading it through a `const Foo*` set at wiring time; or a module pushing bytes to a core sink). Neither is pub/sub: there's one producer per data kind and the consumer explicitly wants that specific data — the registry overhead and listener-lifecycle complexity of pub/sub buy nothing. +Both shapes extend without ceremony to any future producer/consumer pair (a sensor module owning a state struct, an effect reading it through a `const Foo*` set at wiring time; or a module pushing bytes to a core sink). Neither is pub/sub: there's one producer per data kind and the consumer explicitly wants that specific data: the registry overhead and listener-lifecycle complexity of pub/sub buy nothing. ## Event triggering between modules -A control changes, or the module tree is mutated (a child added, deleted, replaced, moved) — and other modules may need to react. The framework provides a three-tier split so each change costs only as much as it has to, from cheapest to most expensive: +A control changes, or the module tree is mutated (a child added, deleted, replaced, moved), and other modules may need to react. The framework provides a three-tier split so each change costs only as much as it has to, from cheapest to most expensive: -1. **`onUpdate(controlName)`** — runs on *every* control change, but only on the module whose own control changed. A cheap, in-place, per-control reaction that touches nothing else: recompute a small derived table, re-bind a socket. Default no-op. -2. **`controlChangeTriggersBuildState(controlName)`** — a gate, default `false`. A module returns `true` only for controls that change the size or shape of its derived state (and thus may ripple to other modules); for controls that just tweak a value in place it stays `false`. When `true`, the framework runs the tree-wide rebuild; when `false`, it doesn't. -3. **`onBuildState()`** — the module (re)builds its derived state (buffers, tables) for the current control values. Reached via `Scheduler::buildState()` — the coordinator-driven sweep that walks every module's `onBuildState`. +1. **`onUpdate(controlName)`**: runs on *every* control change, but only on the module whose own control changed. A cheap, in-place, per-control reaction that touches nothing else: recompute a small derived table, re-bind a socket. Default no-op. +2. **`controlChangeTriggersBuildState(controlName)`**: a gate, default `false`. A module returns `true` only for controls that change the size or shape of its derived state (and thus may ripple to other modules); for controls that just tweak a value in place it stays `false`. When `true`, the framework runs the tree-wide rebuild; when `false`, it doesn't. +3. **`onBuildState()`**: the module (re)builds its derived state (buffers, tables) for the current control values. Reached via `Scheduler::buildState()`, the coordinator-driven sweep that walks every module's `onBuildState`. -`Scheduler::buildState()` fires from two triggers: a tier-2 gate returning true after a control change, **and** any tree mutation (HTTP add/delete/replace/move handlers all call it unconditionally — a structural change is rare and unambiguously needs a rebuild). Both triggers funnel through the same sweep; each module's `onBuildState` is idempotent (e.g. an effect only reallocs when its grid count actually changed), so over-rebuilding is wasted work, not a correctness hazard. +`Scheduler::buildState()` fires from two triggers: a tier-2 gate returning true after a control change, **and** any tree mutation (HTTP add/delete/replace/move handlers all call it unconditionally, since a structural change is rare and unambiguously needs a rebuild). Both triggers funnel through the same sweep; each module's `onBuildState` is idempotent (e.g. an effect only reallocs when its grid count actually changed), so over-rebuilding is wasted work, not a correctness hazard. -This is the recognised layout/prepare-pass pattern: JUCE's `prepareToPlay` and UIKit's `layoutSubviews` work the same way — a framework-driven sweep over every object of the primary type, gated by per-object metadata (WPF's `AffectsMeasure`, here `controlChangeTriggersBuildState`). Not pub/sub: the publisher (HttpServerModule, or the mutation site) explicitly tells the coordinator to run the pass; the coordinator explicitly walks every module. The light domain consumes this mechanism for its mapping rebuild (see [§ Mapping and blending](#mapping-and-blending)) but the mechanism itself is core and applies to any module with derived state. +This is the recognised layout/prepare-pass pattern: JUCE's `prepareToPlay` and UIKit's `layoutSubviews` work the same way: a framework-driven sweep over every object of the primary type, gated by per-object metadata (WPF's `AffectsMeasure`, here `controlChangeTriggersBuildState`). Not pub/sub: the publisher (HttpServerModule, or the mutation site) explicitly tells the coordinator to run the pass; the coordinator explicitly walks every module. The light domain consumes this mechanism for its mapping rebuild (see [§ Mapping and blending](#mapping-and-blending)) but the mechanism itself is core and applies to any module with derived state. -If a module needs to actively notify a specific other module of an event (rather than publish data for polling, or change its own controls), the pattern is a direct method call from the producer to a known consumer — `ImprovProvisioningModule::loop1s` calls `networkModule_->setWifiCredentials(...)` when credentials arrive over UART. No event bus; the producer holds a pointer to the consumer set at wiring time (`main.cpp`). Pub/sub becomes the right pattern only when there are multiple unknown subscribers per event — projectMM has none today. +### Live reconfiguration: every change applies without a reboot + +A direct consequence of the three tiers above is a property worth naming, because it sets projectMM apart from most LED-controller firmware (where changing a pin map, strand length, or output protocol means editing a config and **rebooting**): **every MoonModule reconfigures itself live the instant a control changes — no *configuration* change requires a restart to take effect.** A pin edit, a leds-per-pin edit, a swap of output protocol, a mic pin or sample-rate change — each flows control-write → `onUpdate` (tier 1) and, when it changes the module's shape, → `Scheduler::buildState()` → `onBuildState()` (tier 3), which tears down and rebuilds exactly the derived state that changed: an LED driver re-targets its RMT channels / DMA bus onto the new GPIOs, an audio module re-inits its I²S channel on the new pins, an effect re-sizes its buffer, the Layer rebuilds its mapping LUT. The hot-path render loop reads the rebuilt state on its very next tick. This holds for **all** module types — drivers, the audio peripheral, effects, layouts, modifiers, network I/O — because the rebuild chain is core, not per-module. It composes with the [robustness rule](#robustness): because any change can be applied at any time in any order, a running device tolerates being reconfigured arbitrarily and keeps running (degraded or idle, never crashed). The one thing that still needs a power cycle is a *firmware* OTA flash — a binary swap, not a configuration change, and the same physical boundary the robustness rule draws (power loss, OTA, brown-out are out of scope there too). + +If a module needs to actively notify a specific other module of an event (rather than publish data for polling, or change its own controls), the pattern is a direct method call from the producer to a known consumer: `ImprovProvisioningModule::loop1s` calls `networkModule_->setWifiCredentials(...)` when credentials arrive over UART. No event bus; the producer holds a pointer to the consumer set at wiring time (`main.cpp`). Pub/sub becomes the right pattern only when there are multiple unknown subscribers per event; projectMM has none today. ## Robustness -A running device must tolerate **any sequence of UI actions or API calls** — add, delete, replace, move, or reconfigure any module in any order, at any grid size — and keep running. Degraded or idle is an acceptable outcome; a crash, a hang, or a boot loop is not. This is a defining strongpoint: the device is something an end user can poke at freely without bricking it. +A running device must tolerate **any sequence of UI actions or API calls** (add, delete, replace, move, or reconfigure any module in any order, at any grid size) and keep running. Degraded or idle is an acceptable outcome; a crash, a hang, or a boot loop is not. This is a defining strongpoint: the device is something an end user can poke at freely without bricking it. -The contract is bounded to **what the software accepts as input**. Power loss, a malformed OTA image, a brown-out, or electrical faults are out of scope — the firmware can't intercept those. Everything that arrives through the HTTP API, the WebSocket, or the UI is in scope. +The contract is bounded to **what the software accepts as input**. Power loss, a malformed OTA image, a brown-out, or electrical faults are out of scope; the firmware can't intercept those. Everything that arrives through the HTTP API, the WebSocket, or the UI is in scope. -Why this needs stating as its own guarantee: the mutation-driven rebuild above ([§ Event triggering](#event-triggering-between-modules)) means a single API call can free and rebuild a large slice of the module tree mid-render. The hazard is **stale references** — a module holding a pointer to something that was just torn down. The two patterns that keep it safe: +Why this needs stating as its own guarantee: the mutation-driven rebuild above ([§ Event triggering](#event-triggering-between-modules)) means a single API call can free and rebuild a large slice of the module tree mid-render. The hazard is **stale references**: a module holding a pointer to something that was just torn down. The two patterns that keep it safe: -- **Resolve links at `onBuildState`, don't cache them across mutations.** A module that depends on another (a `Drivers` reading the active `Layer`, a `Layer` reading its `Layouts`) re-resolves that link from the tree at every rebuild rather than pinning a pointer once at wiring time. When the dependency is gone, the link resolves to null — not to freed memory. +- **Resolve links at `onBuildState`, don't cache them across mutations.** A module that depends on another (a `Drivers` reading the active `Layer`, a `Layer` reading its `Layouts`) re-resolves that link from the tree at every rebuild rather than pinning a pointer once at wiring time. When the dependency is gone, the link resolves to null, not to freed memory. - **Tolerate null at the point of use.** Every consumer of a resolved link null-checks it and falls back to an idle state (no buffer, zero lights, nothing sent) rather than dereferencing. A driver with no Layer sends nothing; a Layer with no Layouts reports zero lights. Idle, not crashed. -The enforcement is the test framework, not discipline alone (see the [Hard Rule](../CLAUDE.md#hard-rules)). When a sequence is found that crashes or wedges the device, the fix is **incomplete until a test reproduces that sequence** — so the same break can't return. Worked example: deleting the last Layer once left `Drivers` holding a dangling pointer to the freed Layer; `PreviewDriver` then read it and panicked (`LoadProhibited`), and because the tree persists, the device boot-looped. The fix made `Drivers` clear its drivers' Layer pointers to null when no Layer is active, and a regression test (`unit_PreviewDriver`, "tolerates the active Layer being deleted") drives a Layer delete + rebuild and asserts the driver ends up null, not dangling. The scenario layer adds the same coverage end-to-end: `clear_children` lets a scenario clear a container and rebuild its own pipeline from any starting tree, so the delete/rebuild path is exercised on real hardware, not just in unit tests. +The enforcement is the test framework, not discipline alone (see the [Hard Rule](../CLAUDE.md#hard-rules)). When a sequence is found that crashes or wedges the device, the fix is **incomplete until a test reproduces that sequence**, so the same break can't return. Worked example: deleting the last Layer once left `Drivers` holding a dangling pointer to the freed Layer; `PreviewDriver` then read it and panicked (`LoadProhibited`), and because the tree persists, the device boot-looped. The fix made `Drivers` clear its drivers' Layer pointers to null when no Layer is active, and a regression test (`unit_PreviewDriver`, "tolerates the active Layer being deleted") drives a Layer delete + rebuild and asserts the driver ends up null, not dangling. The scenario layer adds the same coverage end-to-end: `clear_children` lets a scenario clear a container and rebuild its own pipeline from any starting tree, so the delete/rebuild path is exercised on real hardware, not just in unit tests. ## Hot path discipline -The render loop (`Scheduler::tick` and everything it calls — every effect, modifier, driver, layout) is the hot path. It runs roughly 50–10000 times per second depending on light count and CPU performance. Code there obeys three rules: +The render loop (`Scheduler::tick` and everything it calls: every effect, modifier, driver, layout) is the hot path. It runs roughly 50–10000 times per second depending on light count and CPU performance. Code there obeys three rules: -- **No heap allocations.** `new`, `malloc`, `push_back`, `std::string` constructors, `make_unique`, `make_shared` — none of them on the hot path. Heap fragmentation on a long-running ESP32 kills throughput in minutes. Allocate everything during `setup()` / `onBuildState()`; the loop only reads and writes pre-sized buffers. +- **No heap allocations.** `new`, `malloc`, `push_back`, `std::string` constructors, `make_unique`, `make_shared`: none of them on the hot path. Heap fragmentation on a long-running ESP32 kills throughput in minutes. Allocate everything during `setup()` / `onBuildState()`; the loop only reads and writes pre-sized buffers. - **No blocking.** No `delay`, no `sleep`, no `mutex.lock()`. If a mutex is unavoidable, use `try_lock` and skip the work this tick. Blocking the render task means a visible glitch on the LEDs. - **Integer math preferred over `float` in per-light work.** ESP32's FPU is single-precision and not as cheap as integer ALU; per-light float compounds fast. Use fixed-point or scaled integer math where the visual difference doesn't justify the cost. -**Memory layout** is the corollary: allocate buffers as single contiguous blocks outside the hot path. Never allocate many small scattered objects in a loop — fragmentation catches up even off-path. On ESP32 with PSRAM, use `heap_caps_malloc(..., MALLOC_CAP_SPIRAM)` for large buffers; the `platform::alloc` wrapper does this automatically. +**Memory layout** is the corollary: allocate buffers as single contiguous blocks outside the hot path. Never allocate many small scattered objects in a loop; fragmentation catches up even off-path. On ESP32 with PSRAM, use `heap_caps_malloc(..., MALLOC_CAP_SPIRAM)` for large buffers; the `platform::alloc` wrapper does this automatically. **Network input** follows the same discipline: process synchronously at a defined point in the frame loop. Async input with staging buffers is allowed when memory is plentiful (desktop, PSRAM-rich ESP32), but the default is synchronous to keep the loop's worst case predictable. @@ -202,44 +207,44 @@ The render loop (`Scheduler::tick` and everything it calls — every effect, mod Only abstract what you actually need. Currently: -- **Time** — `millis()`, `micros()`. Monotonic, microsecond resolution. (`esp_timer` / `std::chrono`) -- **Memory** — `alloc(size)`, `free(ptr)`. Prefers PSRAM on ESP32, falls back to regular heap. `freeHeap()`, `maxAllocBlock()` for diagnostics. (`heap_caps_malloc` / `std::malloc`) -- **Networking** — `UdpSocket` for ArtNet send. `TcpConnection` / `TcpServer` for HTTP + WebSocket; `TcpConnection::writeChunks` is a non-blocking scatter-gather write so a backpressured browser can't stall the render loop. (lwIP sockets / BSD sockets) -- **Scheduling** — `yield()` (cooperative yield to OS/RTOS), `delayMs(ms)` (blocking sleep, off-path only), `reboot()`. (`vTaskDelay` / `esp_restart` on ESP32; `std::this_thread::sleep_for` / `std::exit` on desktop) -- **Platform config** — `platform_config.h` per platform: compile-time constants like `hasPsram` and `hasWiFi`. Each platform provides its own version; `types.h` includes it without `#ifdef`. Core code branches on these via `if constexpr` (e.g. NetworkModule drops its WiFi cascade when `hasWiFi` is false), so the dead branch is removed from the binary with no `#ifdef` outside `src/platform/`. +- **Time**: `millis()`, `micros()`. Monotonic, microsecond resolution. (`esp_timer` / `std::chrono`) +- **Memory**: `alloc(size)`, `free(ptr)`. Prefers PSRAM on ESP32, falls back to regular heap. `freeHeap()`, `maxAllocBlock()` for diagnostics. (`heap_caps_malloc` / `std::malloc`) +- **Networking**: `UdpSocket` for ArtNet send. `TcpConnection` / `TcpServer` for HTTP + WebSocket; `TcpConnection::writeChunks` is a non-blocking scatter-gather write so a backpressured browser can't stall the render loop. (lwIP sockets / BSD sockets) +- **Scheduling**: `yield()` (cooperative yield to OS/RTOS), `delayMs(ms)` (blocking sleep, off-path only), `delayUs(us)` (microsecond busy-wait, only for sub-millisecond hardware timing a driver owns — e.g. the WS2812 ≥300 µs inter-frame latch in `RmtLedDriver`; never for general pacing, which uses the non-blocking `millis()` gate), `reboot()`. (`vTaskDelay` / `esp_rom_delay_us` / `esp_restart` on ESP32; `std::this_thread::sleep_for` / `std::exit` on desktop) +- **Platform config**: `platform_config.h` per platform: compile-time constants like `hasPsram` and `hasWiFi`. Each platform provides its own version; `types.h` includes it without `#ifdef`. Core code branches on these via `if constexpr` (e.g. NetworkModule drops its WiFi cascade when `hasWiFi` is false), so the dead branch is removed from the binary with no `#ifdef` outside `src/platform/`. Abstractions are added when a concrete implementation needs them, not pre-designed. -**Platform boundary (hard rule).** All `#ifdef`, `#if defined`, platform-specific `#include`s, and hardware API calls live exclusively in `src/platform/`. Everything outside `src/platform/` compiles on every target without modification. Compile-time platform branching uses `if constexpr` on `platform_config.h` flags — never a preprocessor `#ifdef`. The boundary is enforced by [`scripts/check/check_platform_boundary.py`](../scripts/check/check_platform_boundary.py), a commit gate (see [CLAUDE.md § Lifecycle Events](../CLAUDE.md#lifecycle-events)). +**Platform boundary (hard rule).** All `#ifdef`, `#if defined`, platform-specific `#include`s, and hardware API calls live exclusively in `src/platform/`. Everything outside `src/platform/` compiles on every target without modification. Compile-time platform branching uses `if constexpr` on `platform_config.h` flags, never a preprocessor `#ifdef`. The boundary is enforced by [`scripts/check/check_platform_boundary.py`](../scripts/check/check_platform_boundary.py), a commit gate (see [CLAUDE.md § Lifecycle Events](../CLAUDE.md#lifecycle-events)). ## Firmware vs board -**Firmware** is the compiled binary — chip target plus which radios/peripherals/sdkconfig fragments are included. Today's variants: `esp32` (WiFi only), `esp32-eth` (Ethernet only, WiFi excluded), `esp32-eth-wifi` (both), `esp32s3-n16r8` (S3 with 16 MB flash + 8 MB PSRAM). Selected by `build_esp32.py --firmware `, reported by `SystemModule.firmware`, used as the contract target key in scenarios. +**Firmware** is the compiled binary: chip target plus which radios/peripherals/sdkconfig fragments are included. Today's variants: `esp32` (WiFi only), `esp32-eth` (Ethernet only, WiFi excluded), `esp32-eth-wifi` (both), `esp32s3-n16r8` (S3 with 16 MB flash + 8 MB PSRAM), `esp32p4-eth` (Waveshare ESP32-P4-NANO, Ethernet only), `esp32p4-eth-wifi` (the same P4 board with WiFi via its on-board ESP32-C6 over esp_hosted). Selected by `build_esp32.py --firmware `, reported by `SystemModule.firmware`, used as the contract target key in scenarios. -**Board** is the physical hardware — chip + PCB + on-board peripherals (PHY, USB-serial, PSRAM, antenna). Examples: `Olimex ESP32-Gateway Rev G`, `LOLIN D32`, `Generic ESP32 DevKit`. The device cannot identify its own board (no readable PCB ID on classic ESP32), so MoonDeck deduces it from the firmware where unambiguous (`esp32-eth*` ⇒ Olimex) and otherwise lets the user pick. The board name is stored on the device by [BoardModule](moonmodules/core/BoardModule.md) — a code-wired child of SystemModule that holds a single `board` Text control with the `readonly` UI flag (renders display-only on the device's own UI; HTTP `/api/control` writes still apply). MoonDeck mirrors the picked / deduced value to the device via `POST /api/control` after each discover and after every dropdown change. The catalog of valid board names lives at [docs/install/boards.json](install/boards.json), shared between MoonDeck and the web installer — MoonDeck reads it for its dropdown and HTTP `/api/control` push; the web installer reads it for its picker, pushes the picked board via Improv RPC `SET_BOARD` on first flash, and provides an HTTP fallback (Inject button on *Your devices*) when Improv isn't available on the firmware variant. +**Board** is the physical hardware: chip + PCB + on-board peripherals (PHY, USB-serial, PSRAM, antenna). Examples: `Olimex ESP32-Gateway Rev G`, `LOLIN D32`, `Generic ESP32 DevKit`. The device cannot identify its own board (no readable PCB ID on classic ESP32), so MoonDeck deduces it from the firmware where unambiguous (`esp32-eth*` ⇒ Olimex) and otherwise lets the user pick. The board name is stored on the device by [BoardModule](moonmodules/core/BoardModule.md), a code-wired child of SystemModule that holds a single `board` Text control with the `readonly` UI flag (renders display-only on the device's own UI; HTTP `/api/control` writes still apply). MoonDeck mirrors the picked / deduced value to the device via `POST /api/control` after each discover and after every dropdown change. The catalog of valid board names lives at [docs/install/boards.json](install/boards.json), shared between MoonDeck and the web installer: MoonDeck reads it for its dropdown and HTTP `/api/control` push; the web installer reads it for its picker, pushes the picked board via Improv RPC `SET_BOARD` on first flash, and provides an HTTP fallback (Inject button on *Your devices*) when Improv isn't available on the firmware variant. A board can run multiple firmwares (the Olimex Gateway runs both `esp32-eth` and `esp32-eth-wifi`); a firmware can run on multiple boards (`esp32` runs on any classic ESP32 dev board). The `esp32s3-n16r8` firmware is S3-only and does not run on the Olimex Gateway or other classic-ESP32 boards. The codebase reserves "board" exclusively for physical hardware and "firmware" exclusively for the compiled binary. ## Peripherals -A **peripheral** is a MoonModule (role `ModuleRole::Peripheral`) that bridges to the outside world — hardware or network — *independently of the light pipeline*. Examples: a gyro/IMU over I²C, a microphone over I²S, a relay or GPIO toggled out, a status push to Home Assistant. Peripherals are **domain-neutral and live in core**; the platform transport they use (I²C, UART, GPIO) is itself a domain-neutral platform primitive. +A **peripheral** is a MoonModule (role `ModuleRole::Peripheral`) that bridges to the outside world (hardware or network) *independently of the light pipeline*. Examples: a gyro/IMU over I²C, a microphone over I²S, a relay or GPIO toggled out, a status push to Home Assistant. Peripherals are **domain-neutral and live in core**; the platform transport they use (I²C, UART, GPIO) is itself a domain-neutral platform primitive. -> *"Peripheral" here means an external add-on the user wires up — not the ESP32's own on-chip peripherals (LCD_CAM, SPI, PARLIO, RMT). Those on-chip blocks are how **drivers** clock data out to LEDs, reached through the [platform layer](#platform-abstraction); the term is standard for both, but they're different things.* +> *"Peripheral" here means an external add-on the user wires up, not the ESP32's own on-chip peripherals (LCD_CAM, SPI, PARLIO, RMT). Those on-chip blocks are how **drivers** clock data out to LEDs, reached through the [platform layer](#platform-abstraction); the term is standard for both, but they're different things.* -The defining line is the **data relationship, not the connector**: *does the module consume the light output buffer?* If yes it's a **driver** (ArtNet, DMX, SPI-LED all consume the buffer, differing only in transport — a DMX sender uses a peripheral-style UART/RS-485 transport but is a driver because it sends the rendered buffer). If no, it's a **peripheral**. +The defining line is the **data relationship, not the connector**: *does the module consume the light output buffer?* If yes it's a **driver** (ArtNet, DMX, SPI-LED all consume the buffer, differing only in transport; a DMX sender uses a peripheral-style UART/RS-485 transport but is a driver because it sends the rendered buffer). If no, it's a **peripheral**. -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 `.h`+`.cpp` core module under `src/core/` (core file convention), reaches hardware only through a domain-neutral platform primitive (`platform::i2c*` etc.), polls in `loop20ms`/`loop1s` (never the render hot path), and gets a spec in `docs/moonmodules/core/` (enforced by `check_specs.py`). Automatic bus-probe detection is out of scope; the manual path is the foundation. +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/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 (`ImuState { float pitch, roll, … }`) overwritten in place each poll, exposes it via `const ImuState* state() const`, and the effect holds that `const` pointer set once at wiring time in `main.cpp`. A peripheral that only *displays* its readings skips the getter — it's a producer with no consumer yet. The current peripherals are display-only; an audio- or motion-reactive effect would add the getter + wiring (tracked in the [backlog](backlog/backlog.md)). +**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/AudioVolumeEffect.md) and [AudioSpectrumEffect](moonmodules/light/effects/AudioSpectrumEffect.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 -Two domain-neutral services let several controllers act as one installation. They're core because nothing about them is light-specific — any domain spanning multiple devices uses the same two. +Two domain-neutral services let several controllers act as one installation. They're core because nothing about them is light-specific; any domain spanning multiple devices uses the same two. -- **Discovery** — devices find each other via mDNS. `NetworkModule` advertises each device today; this is live. -- **🚧 Clock sync** — one leader broadcasts its elapsed time (millis); followers compute their offset, targeting sub-millisecond accuracy. A shared monotonic clock is the foundation any cross-device coordination builds on. The committed design; not yet wired. +- **Discovery**: devices find each other via mDNS. `NetworkModule` advertises each device today; this is live. +- **🚧 Clock sync**: one leader broadcasts its elapsed time (millis); followers compute their offset, targeting sub-millisecond accuracy. A shared monotonic clock is the foundation any cross-device coordination builds on. The committed design; not yet wired. -What the synced clock is *for* is a domain question — the light domain's use of it (synced animation across a wall) is in [§ Multi-device sync](#multi-device-sync). +What the synced clock is *for* is a domain question; the light domain's use of it (synced animation across a wall) is in [§ Multi-device sync](#multi-device-sync). # Light domain @@ -273,27 +278,27 @@ 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 set once at wiring time and reads it per frame. -- *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). +- *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). -**Naming convention.** Capital `Layouts`, `Layers`, `Drivers` are class names (always capitalised when referring to the class). Lowercase "layouts", "layers", "drivers" is the English plural — used freely when context makes it clear. Singular "layout", "layer", "driver" is an individual instance. +**Naming convention.** Capital `Layouts`, `Layers`, `Drivers` are class names (always capitalised when referring to the class). Lowercase "layouts", "layers", "drivers" is the English plural, used freely when context makes it clear. Singular "layout", "layer", "driver" is an individual instance. ## 3D from the start -The system is natively 3D. Coordinates, effects, layouts, and mappings all operate in 3D space (x, y, z). 2D and 1D are simply the case where one or two dimensions have size 1. There is no separate 2D mode — everything is 3D, and lower dimensions fall out naturally. +The system is natively 3D. Coordinates, effects, layouts, and mappings all operate in 3D space (x, y, z). 2D and 1D are simply the case where one or two dimensions have size 1. There is no separate 2D mode; everything is 3D, and lower dimensions fall out naturally. Two numeric typedefs keep memory tight in LUT tables: -- **`nrOfLightsType`** — total light count, light indices, LUT destinations, `width * height * depth` products. `uint16_t` on devices without PSRAM (max 65 K), `uint32_t` with PSRAM (supports large hub75 panels). Selected at compile time via `platform_config.h`. -- **`lengthType`** — coordinates and dimensions. Always `int16_t` (max 32767 per axis, supports negatives for out-of-bounds effects). +- **`nrOfLightsType`**: total light count, light indices, LUT destinations, `width * height * depth` products. `uint16_t` on devices without PSRAM (max 65 K), `uint32_t` with PSRAM (supports large hub75 panels). Selected at compile time via `platform_config.h`. +- **`lengthType`**: coordinates and dimensions. Always `int16_t` (max 32767 per axis, supports negatives for out-of-bounds effects). For 12 K LEDs with a 1:1 LUT, the smaller `nrOfLightsType` on no-PSRAM devices saves 24 KB. All code uses the typedefs consistently to avoid casting. ## Layouts and Layout -**Layouts** (a MoonModule) is the top-level container for one or more layouts, defining the physical topology of the installation. It is shared by every layer — there is one Layouts describing the physical setup, and every layer renders into it. When a layout changes, every layer rebuilds its LUT. +**Layouts** (a MoonModule) is the top-level container for one or more layouts, defining the physical topology of the installation. It is shared by every layer: there is one Layouts describing the physical setup, and every layer renders into it. When a layout changes, every layer rebuilds its LUT. -A **layout** (a `LayoutBase` MoonModule, child of Layouts) defines the physical positions of lights in 3D space. It is a **coordinate iterator** — it yields `(physicalIndex, x, y, z)` for each light it defines. A layout does not own or build any mapping LUT. +A **layout** (a `LayoutBase` MoonModule, child of Layouts) defines the physical positions of lights in 3D space. It is a **coordinate iterator**: it yields `(physicalIndex, x, y, z)` for each light it defines. A layout does not own or build any mapping LUT. Layouts cover both addressable LEDs and DMX fixtures. An LED-strip layout yields one coordinate per LED; a DMX-fixture layout yields one coordinate per fixture (a moving head is one point in 3D space). @@ -305,18 +310,18 @@ Multiple layouts can live in one Layouts container. Each layout describes one li **Layers** (a MoonModule) is the top-level container for one or more layers. Each layer renders independently into its own buffer; the Drivers container composes those buffers downstream. -**🚧 Multi-layer composition.** The container exists to compose more than one Layer's buffer into the shared output — alpha-blend and additive, in layer order. With a single layer wired (today's boot pipeline) Layers is a thin pass-through, but the design is the multi-layer case: each Layer renders into its own buffer, and the Drivers container's blend+map step composites them in order into the physical buffer (which is why that buffer is described as a *blend* buffer in [§ Memory strategy](#memory-strategy)). The single-layer path is the degenerate case of this, not a separate design. +**🚧 Multi-layer composition.** The container exists to compose more than one Layer's buffer into the shared output: alpha-blend and additive, in layer order. With a single layer wired (today's boot pipeline) Layers is a thin pass-through, but the design is the multi-layer case: each Layer renders into its own buffer, and the Drivers container's blend+map step composites them in order into the physical buffer (which is why that buffer is described as a *blend* buffer in [§ Memory strategy](#memory-strategy)). The single-layer path is the degenerate case of this, not a separate design. A **Layer** (a MoonModule, child of Layers) owns: -- A **buffer** — the light data effects write into (logical space). -- A **mapping LUT** — built by the layer from the shared Layouts and the layer's static modifiers. -- **Effects** (ordered list) — write light values into the buffer. -- **Modifiers** (ordered list) — transform the LUT or light values. +- A **buffer**: the light data effects write into (logical space). +- A **mapping LUT**: built by the layer from the shared Layouts and the layer's static modifiers. +- **Effects** (ordered list): write light values into the buffer. +- **Modifiers** (ordered list): transform the LUT or light values. -A layer can have **multiple effects**. Effects are not blended — they write to the buffer sequentially in their listed order, each overwriting or adding to the previous. That allows stacked patterns (a base-colour effect followed by a sparkle effect). +A layer can have **multiple effects**. Effects are not blended; they write to the buffer sequentially in their listed order, each overwriting or adding to the previous. That allows stacked patterns (a base-colour effect followed by a sparkle effect). -A layer applies its **first enabled modifier** during LUT build (`Layer::rebuildLUT`). Modifier *chaining* — applying several in sequence — is not implemented: only the first enabled modifier takes effect. Order matters for a chain (a multiply-then-checkerboard mask differs from checkerboard-then-multiply, just as mirror-then-rotate differs from rotate-then-mirror), which is why modifiers are reorderable in the UI even though only the first is applied today. Chaining is on the [backlog](backlog/backlog.md) — static modifiers chain during LUT build, dynamic modifiers during rendering. +A layer applies its **first enabled modifier** during LUT build (`Layer::rebuildLUT`). Modifier *chaining* (applying several in sequence) is not implemented: only the first enabled modifier takes effect. Order matters for a chain (a multiply-then-checkerboard mask differs from checkerboard-then-multiply, just as mirror-then-rotate differs from rotate-then-mirror), which is why modifiers are reorderable in the UI even though only the first is applied today. Chaining is on the [backlog](backlog/backlog.md): static modifiers chain during LUT build, dynamic modifiers during rendering. Each layer references the shared Layouts. The layer builds its own LUT by iterating the Layouts container's coordinates and applying its static modifiers in order. Different layers in Layers can have different modifiers, producing different LUTs from the same Layouts. @@ -324,7 +329,7 @@ Each layer references the shared Layouts. The layer builds its own LUT by iterat Effects produce light colours. They write into the Layer's buffer, which represents a logical grid. The Layer determines the buffer's dimensions (width, height, depth) from the Layouts, its own start/end percentages within the physical layout, and its modifiers. Effects receive these logical dimensions and elapsed time (millis) as their rendering context. They compute light positions from the buffer index (e.g. `x = i % width`, `y = i / width`). -Effects use elapsed time for animation, not frame count. Animation speed becomes frame-rate independent — an effect looks the same at 30 fps and 60 fps. This is also what makes the 🚧 cross-device clock sync work: a shared elapsed-time base means synced visuals across controllers (see [§ Multi-device sync](#multi-device-sync)). +Effects use elapsed time for animation, not frame count. Animation speed becomes frame-rate independent: an effect looks the same at 30 fps and 60 fps. This is also what makes the 🚧 cross-device clock sync work: a shared elapsed-time base means synced visuals across controllers (see [§ Multi-device sync](#multi-device-sync)). Effects know nothing about hardware, protocols, physical LED layout, or mapping. They only see the logical grid the layer provides. @@ -332,56 +337,56 @@ Effects know nothing about hardware, protocols, physical LED layout, or mapping. ### Dimensionality -Every effect declares its native dimensionality through `EffectBase::dimensions()`, returning `Dim::D1`, `Dim::D2`, or `Dim::D3` (default — "I iterate every axis the layer gives me"). The Layer uses this to **extrude** lower-dimensional output across the unused axes after each effect's `loop()`: +Every effect declares its native dimensionality through `EffectBase::dimensions()`, returning `Dim::D1`, `Dim::D2`, or `Dim::D3` (default: "I iterate every axis the layer gives me"). The Layer uses this to **extrude** lower-dimensional output across the unused axes after each effect's `loop()`: -- **D1** — the effect writes only the row at `(y=0, z=0)`. Layer copies that row across every other y in z=0, then copies z=0 across every z. -- **D2** — the effect writes only the z=0 slice. Layer copies z=0 across every z. -- **D3** — the effect writes every axis itself. Extrude is a one-comparison no-op. +- **D1**: the effect writes only the row at `(y=0, z=0)`. Layer copies that row across every other y in z=0, then copies z=0 across every z. +- **D2**: the effect writes only the z=0 slice. Layer copies z=0 across every z. +- **D3**: the effect writes every axis itself. Extrude is a one-comparison no-op. 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 y and z). Effects that don't make that promise stay at the D3 default and iterate the whole buffer. -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 layer) the inner loops are guarded by `depth_ > 1` / `height_ > 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. +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 layer) the inner loops are guarded by `depth_ > 1` / `height_ > 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. Each effect's `dimensions()` is a claim about which axes its loop iterates, not which axes its math could in principle vary along. A "D2 fire" can in future be promoted to D3 by adding z-aware heat propagation; until then declaring it D2 honestly describes what the loop does today. -The `dim` int is also emitted in `/api/types` so the UI derives the dimensional emoji (📏/🟦/🧊) per module — modules don't put dimensional emoji in their own `tags()` strings. +The `dim` int is also emitted in `/api/types` so the UI derives the dimensional emoji (📏/🟦/🧊) per module; modules don't put dimensional emoji in their own `tags()` strings. ### Robustness rules -**Effects must run at every grid size.** Modifiers can shrink the logical grid to any size including 0×0×0 (e.g. every layout child is disabled). An effect's `loop()` must produce a correct result for any `(width, height, depth)` — no crashes, no divide-by-zero, no out-of-bounds writes. On a zero grid the loop is a clean no-op. Effects either gate at the top (`if (w <= 0 || h <= 0) return;`) or write their loops so an empty range is naturally a no-op (`for (y = 0; y < h; ...)`). +**Effects must run at every grid size.** Modifiers can shrink the logical grid to any size including 0×0×0 (e.g. every layout child is disabled). An effect's `loop()` must produce a correct result for any `(width, height, depth)`: no crashes, no divide-by-zero, no out-of-bounds writes. On a zero grid the loop is a clean no-op. Effects either gate at the top (`if (w <= 0 || h <= 0) return;`) or write their loops so an empty range is naturally a no-op (`for (y = 0; y < h; ...)`). -**Effects must animate at every tick rate.** Per-tick phase math computed as `dt * bpm * K / 60000` truncates to 0 on devices where `dt < 234/bpm` ms — desktop ticks every 0–1 ms, so even bpm=60 freezes. The fix is to keep the raw `dt * bpm` numerator in the phase accumulator and divide only at the read site: +**Effects must animate at every tick rate.** Per-tick phase math computed as `dt * bpm * K / 60000` truncates to 0 on devices where `dt < 234/bpm` ms: desktop ticks every 0–1 ms, so even bpm=60 freezes. The fix is to keep the raw `dt * bpm` numerator in the phase accumulator and divide only at the read site: ```cpp phase_num_ += static_cast(dt) * bpm; uint8_t t = static_cast((phase_num_ * 256) / 60000); ``` -See NoiseEffect / MetaballsEffect for the canonical pattern. Animation speed must depend only on `bpm` and wallclock — not on tick rate or grid size. +See NoiseEffect / MetaballsEffect for the canonical pattern. Animation speed must depend only on `bpm` and wallclock, not on tick rate or grid size. ## Modifiers -A modifier (MoonModule) lives inside a layer alongside its effects. Modifiers expose a virtual interface — the Layer calls modifier methods without knowing the concrete type (no `dynamic_cast`). +A modifier (MoonModule) lives inside a layer alongside its effects. Modifiers expose a virtual interface: the Layer calls modifier methods without knowing the concrete type (no `dynamic_cast`). A modifier can: -- Transform the mapping LUT via `transformCoord()` — rebuilt on the cold path, zero render cost. -- Transform light values via `transformLights()` on the hot path — per-light cost, enables dynamic animations like rotation. +- Transform the mapping LUT via `transformCoord()`: rebuilt on the cold path, zero render cost. +- Transform light values via `transformLights()` on the hot path: per-light cost, enables dynamic animations like rotation. -**Dimensionality** for modifiers defaults to `Dim::D3` (assumed to work in all three axes unless declared otherwise). Unlike for effects, this is purely advisory — the Layer doesn't extrude modifier output. It exists so the UI can render the 📏/🟦/🧊 chip on the card. **MultiplyModifier** is D3 (it has independent multiplyX/Y/Z + mirrorX/Y/Z toggles). +**Dimensionality** for modifiers defaults to `Dim::D3` (assumed to work in all three axes unless declared otherwise). Unlike for effects, this is purely advisory: the Layer doesn't extrude modifier output. It exists so the UI can render the 📏/🟦/🧊 chip on the card. **MultiplyModifier** is D3 (it has independent multiplyX/Y/Z + mirrorX/Y/Z toggles). ## Mapping and blending The blend+map step walks each layer in turn: reads each logical light, uses that layer's LUT to find the physical position(s), blends the colour into the physical output buffer. This is where logical space meets physical space. -Each mapping LUT is a flat, contiguous lookup table allocated outside the hot path. It is built in `Layer::onBuildState()` and rebuilt whenever a Layout or Modifier control changes (the controls' `controlChangeTriggersBuildState` returns true) or a Modifier/Layout child is added/removed/replaced/moved — both triggers flow through the same core mechanism, see [§ Event triggering between modules](#event-triggering-between-modules). +Each mapping LUT is a flat, contiguous lookup table allocated outside the hot path. It is built in `Layer::onBuildState()` and rebuilt whenever a Layout or Modifier control changes (the controls' `controlChangeTriggersBuildState` returns true) or a Modifier/Layout child is added/removed/replaced/moved; both triggers flow through the same core mechanism, see [§ Event triggering between modules](#event-triggering-between-modules). The LUT supports four mapping types: -- **1:1 identical** — logical index equals physical index. No table needed (`hasLUT()` returns false, `setIdentity()` mode). Grid without serpentine, no modifiers. -- **1:1 shuffled** — logical maps to one physical, but reordered. Table needed. Grid with serpentine. -- **1:0 unmapped** — logical light has no physical output. Table needed. Sparse layouts (wheel). -- **1:N multimap** — logical maps to multiple physical positions. Table needed (CSR format). Mirror / clone modifier. +- **1:1 identical**: logical index equals physical index. No table needed (`hasLUT()` returns false, `setIdentity()` mode). Grid without serpentine, no modifiers. +- **1:1 shuffled**: logical maps to one physical, but reordered. Table needed. Grid with serpentine. +- **1:0 unmapped**: logical light has no physical output. Table needed. Sparse layouts (wheel). +- **1:N multimap**: logical maps to multiple physical positions. Table needed (CSR format). Mirror / clone modifier. Because mapping and blending happen in a single pass over each layer, there is no intermediate "mapped but unblended" buffer. The physical buffer is the only output-side allocation. @@ -389,32 +394,32 @@ Because mapping and blending happen in a single pass over each layer, there is n **Drivers** (a MoonModule) is the top-level container for one or more drivers. It is the consumer side of the pipeline. The Drivers container owns a shared output buffer and performs blend+map from every layer's buffer into it each frame. Individual drivers then read from this buffer to push to hardware / network. -The shared output buffer is necessary when blend+map writes to arbitrary physical positions via the LUT — the output is not filled sequentially, so a driver cannot read chunk-by-chunk until the full buffer is populated. It is *not* needed for the single-layer, no-blend case (identity or serpentine-shuffle mapping): there a driver can fuse map + output correction + protocol encode into one pass straight into its own output (DMA buffer / packet), skipping the shared buffer. Full detail in [the LED-driver design doc](backlog/leddriver-analysis-top-down.md). +The shared output buffer is necessary when blend+map writes to arbitrary physical positions via the LUT: the output is not filled sequentially, so a driver cannot read chunk-by-chunk until the full buffer is populated. It is *not* needed for the single-layer, no-blend case (identity or serpentine-shuffle mapping): there a driver can fuse map + output correction + protocol encode into one pass straight into its own output (DMA buffer / packet), skipping the shared buffer. Full detail in [the LED-driver design doc](backlog/leddriver-analysis-top-down.md). Each driver (a MoonModule) speaks one protocol: -- **LED drivers** — WS2812 via RMT, APA102 via SPI. Platform-specific. -- **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. +- **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. +- **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. Each driver child reads from the Drivers container's output buffer. Everything before the Drivers container is platform-independent. -**Output correction** turns logical RGB into the physical signal every physical driver needs: **brightness** scaling, channel **reorder** (RGB→GRB etc. via a *light preset*), and **white** derivation for RGBW fixtures. The Drivers container owns the shared correction state — a brightness lookup table plus the light-preset — exposed as `brightness` and `lightPreset` controls. Each *physical* driver applies the correction per-light as it reads its source buffer, into its own output buffer/packet. Preview is exempt: it shows the raw logical buffer (the effect's true output, not the dimmed/reordered wire signal). ArtNet consumes the correction today via a `const Correction*` set by `Drivers`; any other physical driver added to `Drivers` consumes the same pointer. The brightness LUT rebuilds on the cheap `onUpdate` tier (see [§ Event triggering between modules](#event-triggering-between-modules)), so the slider stays fluent. +**Output correction** turns logical RGB into the physical signal every physical driver needs: **brightness** scaling, channel **reorder** (RGB→GRB etc. via a *light preset*), and **white** derivation for RGBW fixtures. The Drivers container owns the shared correction state, a brightness lookup table plus the light-preset, exposed as `brightness` and `lightPreset` controls. Each *physical* driver applies the correction per-light as it reads its source buffer, into its own output buffer/packet. Preview is exempt: it shows the raw logical buffer (the effect's true output, not the dimmed/reordered wire signal). Every physical driver consumes the correction via a `const Correction*` set by `Drivers`, the RMT / LCD_CAM / Parlio LED drivers and `NetworkSendDriver` alike; `Drivers` hands the same pointer to each child it wires. The brightness LUT rebuilds on the cheap `onUpdate` tier (see [§ Event triggering between modules](#event-triggering-between-modules)), so the slider stays fluent. -Network-based drivers (ArtNet, E1.31, DDP) must pace their packet output — never blast all universe packets in a tight loop. Both FPS limiting (skip frames if called too fast) and inter-packet delay (microsecond pause between universes within a frame) are required. Without pacing, receivers drop packets and the output appears broken. +Network-based drivers (ArtNet, E1.31, DDP) pace their output with a **non-blocking elapsed-time gate**, never a blocking wait (no `delay`/`vTaskDelay` — that would stall the single-threaded tick, the hot-path rule). The gate is the `lastSendTime`/`millis()` pattern: `if (now − lastSendTime < interval) return;` early-exits the tick so every other module's loop keeps running, exactly how FPS limiting works (`NetworkSendDriver`, `fps` control). **Frame-rate pacing is required** and implemented this way. **Inter-packet pacing** (spacing the universes within one frame) uses the same non-blocking gate *if* a receiver drops packets under a burst — it is not needed by default (the bench ArtNet matrix test runs clean bursting the universes), so it is added only when a target requires it, never as a busy-wait between packets. ## Memory strategy -All buffers are allocated as single contiguous blocks outside the hot path — at startup or when configuration changes (LED count, layout size, layer count). They are then reused every frame with zero allocations in steady state. Measured per-module timing and memory for each platform: [performance.md](performance.md). +All buffers are allocated as single contiguous blocks outside the hot path, at startup or when configuration changes (LED count, layout size, layer count). They are then reused every frame with zero allocations in steady state. Measured per-module timing and memory for each platform: [performance.md](performance.md). ### Buffer types -- **Layer buffers** — one per active layer, holds the logical light data for one effect chain. Allocated in PSRAM when available. On memory-constrained devices, consumers may read from the layer buffer directly (no mapping, no blending, no physical buffer needed). -- **Physical buffer** — when present, holds the blended+mapped output. It is a *blend* buffer, needed only for compositing (>1 layer, or any alpha/additive blend); it is not what provides producer/consumer parallelism. Under the 🚧 [two-core handover](#parallelism), parallelism comes from the consumer's own working copy — the encoded DMA buffer for a clockless LED driver, or the kernel socket buffer for ArtNet — which decouples the producer (filling the next Layer frame) from the consumer (transmitting the previous one). -- **Mapping LUT** — flat lookup table for logical→physical. Read-only during rendering. PSRAM is fine — sequential reads are cache-friendly. +- **Layer buffers**: one per active layer, holds the logical light data for one effect chain. Allocated in PSRAM when available. On memory-constrained devices, consumers may read from the layer buffer directly (no mapping, no blending, no physical buffer needed). +- **Physical buffer**: when present, holds the blended+mapped output. It is a *blend* buffer, needed only for compositing (>1 layer, or any alpha/additive blend); it is not what provides producer/consumer parallelism. Under the 🚧 [two-core handover](#parallelism), parallelism comes from the consumer's own working copy, the encoded DMA buffer for a clockless LED driver, or the kernel socket buffer for ArtNet, which decouples the producer (filling the next Layer frame) from the consumer (transmitting the previous one). +- **Mapping LUT**: flat lookup table for logical→physical. Read-only during rendering. PSRAM is fine: sequential reads are cache-friendly. -All buffers are raw `uint8_t*` arrays sized `channelsPerLight * nrOfLights`. There is no pre-allocated per-channel array and no fixed channel layout — `channelsPerLight` is a runtime value (a `uint8_t`, so 1–255), so RGB (3), RGBW (4), and multi-channel DMX fixtures all use the same code path; the buffer simply gets wider. Channel layout is configured via offsets (see MoonLight's [LightsHeader](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Layers/LightsHeader.h) pattern). +All buffers are raw `uint8_t*` arrays sized `channelsPerLight * nrOfLights`. There is no pre-allocated per-channel array and no fixed channel layout: `channelsPerLight` is a runtime value (a `uint8_t`, so 1–255), so RGB (3), RGBW (4), and multi-channel DMX fixtures all use the same code path; the buffer simply gets wider. Channel layout is configured via offsets (see MoonLight's [LightsHeader](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Layers/LightsHeader.h) pattern). Network input (ArtNet receive, WebSocket) is processed synchronously at a defined point in the frame loop. Zero extra buffers, no race conditions. The trade-off is up to one frame of latency (~16 ms at 60 fps), imperceptible for LEDs. @@ -429,9 +434,9 @@ The system checks available heap before each allocation and degrades gracefully Best to worst: -1. **Full pipeline** — LUT + driver output buffer. Modifier applied, clean separation. -2. **Skip LUT + driver buffer** — modifier not applied, forced 1:1 mapping. No intermediate buffers. (A LUT without a driver buffer to map into is useless — they're always skipped together.) -3. **Reduce layer dimensions** — halve width/height until the buffer fits, minimum 8×8. +1. **Full pipeline**: LUT + driver output buffer. Modifier applied, clean separation. +2. **Skip LUT + driver buffer**: modifier not applied, forced 1:1 mapping. No intermediate buffers. (A LUT without a driver buffer to map into is useless; they're always skipped together.) +3. **Reduce layer dimensions**: halve width/height until the buffer fits, minimum 8×8. Each degradation is observable via `lutSkipped()` and reported in `/api/system` per-module metrics. @@ -441,11 +446,11 @@ Non-negotiable: - Effects always write to their layer's logical buffer. Never to output, never to physical coordinates. - Drivers always own the output path (blending, mapping, brightness correction, channel reordering). -- Layer buffer is mandatory — if it doesn't fit, reduce dimensions until it does ("at least see something"). +- Layer buffer is mandatory: if it doesn't fit, reduce dimensions until it does ("at least see something"). ### Per-module reporting -Every MoonModule self-reports `classSize()` / `dynamicBytes()` / `loopTimeUs()` (a core base-class feature — see [§ MoonModules](#moonmodules)). For the light pipeline specifically, memory scenarios use those numbers to verify that 1:1 pipelines allocate zero intermediate buffers and that the degradation cascade triggers at the right thresholds. +Every MoonModule self-reports `classSize()` / `dynamicBytes()` / `loopTimeUs()` (a core base-class feature; see [§ MoonModules](#moonmodules)). For the light pipeline specifically, memory scenarios use those numbers to verify that 1:1 pipelines allocate zero intermediate buffers and that the degradation cascade triggers at the right thresholds. ### Scaling to available memory @@ -462,28 +467,28 @@ The architecture does not assume PSRAM is present. Buffer counts and sizes are d How lighting uses the core [multi-device runtime](#multi-device-runtime) (discovery + clock sync) to drive an installation spanning multiple controllers: -- **🚧 Synced visuals from the shared clock.** Effects animate off elapsed time ([§ Effects](#effects)), so feeding them the leader's synced clock instead of each device's local one makes a wall of controllers animate in lockstep — regardless of each one's frame rate. This is the light-domain payoff of the core clock sync. -- **🚧 Light distribution** — one device sending rendered light data to another uses the existing ArtNet / E1.31 / DDP standards (the ArtNet *driver* already sends to fixtures today; device-to-device distribution as a sync topology is the part not yet wired). No bespoke protocol. +- **🚧 Synced visuals from the shared clock.** Effects animate off elapsed time ([§ Effects](#effects)), so feeding them the leader's synced clock instead of each device's local one makes a wall of controllers animate in lockstep, regardless of each one's frame rate. This is the light-domain payoff of the core clock sync. +- **🚧 Light distribution**: one device sending rendered light data to another uses the existing ArtNet / E1.31 / DDP standards (the ArtNet *driver* already sends to fixtures today; device-to-device distribution as a sync topology is the part not yet wired). No bespoke protocol. # Web UI ![UI overview](assets/screenshots/ui_overview.png) -The UI is a handful of hand-maintained files — `index.html`, `app.js`, `style.css`, plus two focused ES modules `app.js` imports (`preview3d.js` for the WebGL 3D preview, `install-picker.js` shared with the web installer). No frameworks, no build tools, no npm. Served directly by the embedded HTTP server. +The UI is a handful of hand-maintained files: `index.html`, `app.js`, `style.css`, plus two focused ES modules `app.js` imports (`preview3d.js` for the WebGL 3D preview, `install-picker.js` shared with the web installer). No frameworks, no build tools, no npm. Served directly by the embedded HTTP server. -The UI is **MoonModule-driven**. It contains no hard-coded knowledge of specific effects, layouts, or drivers. It queries the system for the current MoonModule tree (layers, effects, modifiers, layouts, drivers — each with their controls) and renders generically: +The UI is **MoonModule-driven**. It contains no hard-coded knowledge of specific effects, layouts, or drivers. It queries the system for the current MoonModule tree (layers, effects, modifiers, layouts, drivers, each with their controls) and renders generically: - Each MoonModule shows as a card with its name and declared controls. - Controls are auto-rendered by type (slider, toggle, colour picker, text input, dropdown). - Modules can be switched (change which effect a layer uses) and linked (assign a layout to a layer). -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. +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.md](moonmodules/core/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/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.md](moonmodules/core/ui.md). ## What we leave undesigned -Genuinely open questions — *not* the same as a 🚧 marker. A 🚧 item is a committed design that simply isn't coded yet (multi-layer composition, two-core handover, time sync); the items here are ones where the *design itself* isn't settled, deferred until a concrete need forces the decision: +Genuinely open questions, *not* the same as a 🚧 marker. A 🚧 item is a committed design that simply isn't coded yet (multi-layer composition, two-core handover, time sync); the items here are ones where the *design itself* isn't settled, deferred until a concrete need forces the decision: -- **WiFi runtime disable** — today the eth-only build profile compiles WiFi out. Whether runtime gating should key off detected hardware presence, an explicit control, or a board-catalog field isn't decided; the eth-only build covers the need until one is. -- **Mixing light types in one Layouts** — each layout child describes one light type (all LED strips, or all par lights). Whether a single Layouts container should hold mixed types (LED strips + par lights together), and how the channel layout would reconcile across them, isn't designed; one Layouts per light type is the current model. +- **WiFi runtime disable**: today the eth-only build profile compiles WiFi out. Whether runtime gating should key off detected hardware presence, an explicit control, or a board-catalog field isn't decided; the eth-only build covers the need until one is. +- **Mixing light types in one Layouts**: each layout child describes one light type (all LED strips, or all par lights). Whether a single Layouts container should hold mixed types (LED strips + par lights together), and how the channel layout would reconcile across them, isn't designed; one Layouts per light type is the current model. diff --git a/docs/assets/boards/ESP32-P4-NANO-details-inter.jpg b/docs/assets/boards/ESP32-P4-NANO-details-inter.jpg new file mode 100644 index 00000000..9df8bb84 Binary files /dev/null and b/docs/assets/boards/ESP32-P4-NANO-details-inter.jpg differ diff --git a/docs/assets/boards/esp32-d0-16mb.jpg b/docs/assets/boards/esp32-d0-16mb.jpg new file mode 100644 index 00000000..9e160797 Binary files /dev/null and b/docs/assets/boards/esp32-d0-16mb.jpg differ diff --git a/docs/assets/boards/esp32-p4-nano.jpg b/docs/assets/boards/esp32-p4-nano.jpg new file mode 100644 index 00000000..c1c656c0 Binary files /dev/null and b/docs/assets/boards/esp32-p4-nano.jpg differ diff --git a/docs/assets/boards/esp32-s3-n16r8v.jpg b/docs/assets/boards/esp32-s3-n16r8v.jpg new file mode 100644 index 00000000..588d133d Binary files /dev/null and b/docs/assets/boards/esp32-s3-n16r8v.jpg differ diff --git a/docs/assets/screenshots/CheckerboardModifier.gif b/docs/assets/screenshots/CheckerboardModifier.gif new file mode 100644 index 00000000..9538f57e Binary files /dev/null and b/docs/assets/screenshots/CheckerboardModifier.gif differ diff --git a/docs/assets/screenshots/CheckerboardModifier.png b/docs/assets/screenshots/CheckerboardModifier.png new file mode 100644 index 00000000..187744b2 Binary files /dev/null and b/docs/assets/screenshots/CheckerboardModifier.png differ diff --git a/docs/assets/screenshots/GameOfLifeEffect.gif b/docs/assets/screenshots/GameOfLifeEffect.gif new file mode 100644 index 00000000..a0715a51 Binary files /dev/null and b/docs/assets/screenshots/GameOfLifeEffect.gif differ diff --git a/docs/assets/screenshots/GameOfLifeEffect.png b/docs/assets/screenshots/GameOfLifeEffect.png new file mode 100644 index 00000000..1fae96ad Binary files /dev/null and b/docs/assets/screenshots/GameOfLifeEffect.png differ diff --git a/docs/assets/screenshots/MultiplyModifier.gif b/docs/assets/screenshots/MultiplyModifier.gif new file mode 100644 index 00000000..0f160de0 Binary files /dev/null and b/docs/assets/screenshots/MultiplyModifier.gif differ diff --git a/docs/assets/screenshots/MultiplyModifier.png b/docs/assets/screenshots/MultiplyModifier.png new file mode 100644 index 00000000..9919da1d Binary files /dev/null and b/docs/assets/screenshots/MultiplyModifier.png differ diff --git a/docs/assets/screenshots/ArtNetSendDriver.png b/docs/assets/screenshots/NetworkSendDriver.png similarity index 100% rename from docs/assets/screenshots/ArtNetSendDriver.png rename to docs/assets/screenshots/NetworkSendDriver.png diff --git a/docs/assets/screenshots/RingsEffect.gif b/docs/assets/screenshots/RingsEffect.gif new file mode 100644 index 00000000..e83625f9 Binary files /dev/null and b/docs/assets/screenshots/RingsEffect.gif differ diff --git a/docs/assets/screenshots/RingsEffect.png b/docs/assets/screenshots/RingsEffect.png new file mode 100644 index 00000000..046db84b Binary files /dev/null and b/docs/assets/screenshots/RingsEffect.png differ diff --git a/docs/assets/screenshots/RipplesEffect.gif b/docs/assets/screenshots/RipplesEffect.gif index 85b72ce3..ffa1a7d7 100644 Binary files a/docs/assets/screenshots/RipplesEffect.gif and b/docs/assets/screenshots/RipplesEffect.gif differ diff --git a/docs/assets/screenshots/RipplesEffect.png b/docs/assets/screenshots/RipplesEffect.png index c66c53f9..b721a100 100644 Binary files a/docs/assets/screenshots/RipplesEffect.png and b/docs/assets/screenshots/RipplesEffect.png differ diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 7c0946e4..9a4d2039 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -19,3 +19,4 @@ One-off research documents that informed a future direction, kept for the reason - [leddriver-analysis-top-down.md](leddriver-analysis-top-down.md) — reasons from the end goal (driving WS2812-class LEDs from a GPIO pin) toward a generic driver architecture, per-platform implementation, and a testing strategy. - [leddriver-analysis-bottom-up.md](leddriver-analysis-bottom-up.md) — the companion landscape survey: catalogues the existing LED-driver libraries across ESP32, Teensy, Raspberry Pi, and PC, and recommends a path. - [leddriver-increment-1-plan.md](leddriver-increment-1-plan.md) — the concrete first-increment plan distilled from the two analyses: RMT/WS2812B on classic ESP32, the unified one-base driver hierarchy (ArtNet + LED + Preview as peer interpreters of the light preset), the platform seam, and the loopback + host-encoder test strategy. Locked product-owner decisions at the top. +- [leddriver-increment-2-plan.md](leddriver-increment-2-plan.md) — the second increment: 2a multi-pin RMT (implemented; classic + S3 via SOC capability constants) and 2b parallel LCD_CAM on the S3 (open: lane count and LEDs-per-lane targets). Locked decisions and the deferred per-driver buffer window at the top. diff --git a/docs/backlog/backlog.md b/docs/backlog/backlog.md index f03c89b4..2ec61ea9 100644 --- a/docs/backlog/backlog.md +++ b/docs/backlog/backlog.md @@ -28,6 +28,20 @@ Completed items are removed. This file is deleted when empty. ## ESP32 performance and memory +### Intermittent ~0.5 s LED pauses with the RMT driver (pending investigation) + +Observed on the bench (2026-06): LED output running on the RMT driver occasionally freezes for about half a second. Postponed by the product owner until more observations exist. Ranked suspects from the initial analysis, each with a cheap experiment: + +1. **WiFi modem power-save never disabled** — nothing in `src/` calls `esp_wifi_set_ps(WIFI_PS_NONE)`, so the IDF default `WIFI_PS_MIN_MODEM` is active; the radio's DTIM sleep causes exactly this class of intermittent multi-hundred-ms stall. WLED and the v1/v2 lineage disable sleep. Experiment: one line in the ESP32 platform code after association. +2. **NetworkSendDriver sending synchronously every tick to an absent destination** (default `192.168.1.70`) — lwIP keeps re-ARPing a dead address while the send sits in the render tick. Data point (2026-06-10): the bench esp32-16mb had NetworkSend *disabled* in its persisted config, consistent with the pauses being annoying enough to switch the sender off. Experiment: point the ArtNet IP at a live host (or disable the driver) and see if the pauses stop. +3. **`rmt_tx_wait_all_done` 1 s timeout** — a wedged transmission blocks the tick up to a full second (multi-pin: up to N×1 s). Least likely (~1 s, not ~0.5 s) but it's the only hard block in the driver itself. + +If pauses correlate with UI control changes, also consider the 2 s-debounced SPIFFS save stalling flash-resident code. The per-tick KPI log around a pause discriminates between these immediately. + +### E1.31 multicast receive (IGMP join) + +NetworkReceiveEffect accepts E1.31 via unicast only — the same scope MoonLight ships. Multicast senders address the per-universe group `239.255.{universe_hi}.{universe_lo}`, which a receiver must join via IGMP; the platform `UdpSocket` has no `IP_ADD_MEMBERSHIP` support yet (lwIP `setsockopt` on ESP32, plain `setsockopt` on desktop, plus a join-per-accepted-universe bookkeeping question). Add when a multicast-only sender actually shows up on a bench; until then the spec documents "point sACN senders at the device's IP". + ### WiFi ArtNet performance (pending investigation) 128×128 WiFi ArtNet measurements exist (see [performance.md](../performance.md) "ArtNet over WiFi" and "Build-variant WiFi comparison"). Remaining matrix: @@ -37,9 +51,13 @@ Completed items are removed. This file is deleted when empty. This determines the practical LED limit for WiFi-only boards. Until the `sdkconfig.defaults` TX-buffer fix lands (identified in the build-variant table), **use `esp32-eth-wifi` for any ArtNet workload on classic ESP32** even if Ethernet isn't physically connected. +### Network round-trip test — drop/reorder measurement (deferred) + +`scripts/scenario/run_network_roundtrip.py` measures PC→device→PC **latency and jitter** per protocol (ArtNet/E1.31/DDP) by timing how long a sent colour takes to appear in the device's preview stream. It deliberately does **not** measure per-frame **drops or reorder**, because the path can't track individual frames cleanly: `NetworkSendDriver` re-clocks at its own fps (decoupled from receive) and `NetworkReceiveEffect` holds-last-frame, so frames don't pass through 1:1 — a sequence number embedded in frame N may be re-sent 0, 1, or several times downstream. The min/median/max spread the test already reports *is* the jitter signal (it surfaced multi-second outliers on the classic ESP32). To measure true drop/reorder, the firmware would need a sequence-faithful echo path (e.g. a `NetworkReceiveEffect` echo mode that re-emits each received frame 1:1 back to the sender, bypassing the fps re-clock), then the PC could match sent↔received sequence numbers. The test's docstring lists this under "extend later" alongside per-frame sequence matching and the device→device chain. + ### Async ArtNet send — decouple the wire from the render tick (PSRAM-only) -The ArtNet send is synchronous: `ArtNetSendDriver::loop()` blasts ~97 universes (a 48 KB frame at 128×128) inline, and the per-universe `send()` blocks on lwIP TX backpressure — the netif/EMAC (or WiFi) drivers throttle to wire throughput. Measured on hardware: **~35 ms over Ethernet, ~90 ms over WiFi**, charged straight to the render tick, so ArtNet alone caps the Olimex at ~15 FPS and the S3 (WiFi) at ~7 FPS at 128×128. This is a transport throughput limit, **not** something a non-blocking socket can shed — verified that neither `O_NONBLOCK` nor `MSG_DONTWAIT` makes lwIP return early for UDP (the block is below the socket API; both flags drop zero packets and cost the same ~35 ms). The earlier "non-blocking recovered it to ~2 ms" reading was a transient external condition (the receiver/switch draining the burst freely in one window), unreproducible under steady load with the exact firmware. +The ArtNet send is synchronous: `NetworkSendDriver::loop()` blasts ~97 universes (a 48 KB frame at 128×128) inline, and the per-universe `send()` blocks on lwIP TX backpressure — the netif/EMAC (or WiFi) drivers throttle to wire throughput. Measured on hardware: **~35 ms over Ethernet, ~90 ms over WiFi**, charged straight to the render tick, so ArtNet alone caps the Olimex at ~15 FPS and the S3 (WiFi) at ~7 FPS at 128×128. This is a transport throughput limit, **not** something a non-blocking socket can shed — verified that neither `O_NONBLOCK` nor `MSG_DONTWAIT` makes lwIP return early for UDP (the block is below the socket API; both flags drop zero packets and cost the same ~35 ms). The earlier "non-blocking recovered it to ~2 ms" reading was a transient external condition (the receiver/switch draining the burst freely in one window), unreproducible under steady load with the exact firmware. The real fix is a **dedicated send task**: `loop()` snapshots the corrected frame into a handoff buffer and signals the task; the send task drains it to the wire at its own pace while the render task continues. The tick stops paying the ~35–90 ms — render runs at its own rate (~30 FPS on the Olimex), ArtNet streams independently at whatever the link sustains. @@ -49,7 +67,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 ([ArtNetSendDriver.md](../moonmodules/light/drivers/ArtNetSendDriver.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/drivers/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. @@ -108,6 +126,18 @@ Not blocking — MoonDeck is a developer tool, not a production server. Pick thi Added as a diagnostic tool during performance investigation; testing showed mDNS has zero FPS impact. Evaluate whether to keep (useful for debugging on other boards) or remove (unnecessary complexity). Decide after WiFi performance testing above. +### Static IP on WiFi STA — wire the existing fields to the network (backlog) + +NetworkModule exposes `addressing` (DHCP / Static) plus `ip` / `gateway` / `subnet` / `dns` fields, and they persist — but they are **not applied to the WiFi STA interface**. `wifiStaInit(ssid, password)` takes only credentials; the STA always runs DHCP (there is no `esp_netif_dhcpc_stop` + `esp_netif_set_ip_info` on `staNetif_` — that pattern exists only for the AP). So selecting Static and entering an IP currently does nothing: the device keeps its DHCP lease. The fields are display-only scaffolding ahead of the functionality. + +Implementing it needs to answer three UX/safety questions (these *are* the spec): + +- **When is it applied?** NOT per-keystroke — editing the fields must only update the stored values, never reconfigure the live interface mid-entry (a valid `ip` with a still-zero `gateway` would otherwise be applied and break routing). Apply on an explicit commit — safest is **on next connect / reboot**, not a live switch, because changing the STA IP drops the very connection the browser UI is talking to. +- **Validation before apply.** Require all of ip/gateway/subnet present and self-consistent; reject `0.0.0.0` gateway/ip. If invalid, stay on DHCP rather than half-apply. +- **Warn before a live change.** If applied live (not reboot-deferred), the UI must confirm ("about to change this device's IP to X — you'll need to reconnect at the new address") and surface the new URL, since the current socket dies the instant the IP changes. + +Platform work: extend `wifiStaInit` (or add `wifiStaSetStatic`) to take optional ip/gateway/subnet/dns and call `esp_netif_dhcpc_stop` + `esp_netif_set_ip_info` on `staNetif_` when addressing is Static and the config validates. Needs careful hardware testing — a wrong static config locks the device off-network (recovery is the AP-fallback path or a flash erase). Until landed, consider hiding the Static option so it doesn't read as functional. + ### Memory ceiling on non-PSRAM ESP32 with eth-wifi (backlog) On `esp32-eth-wifi`, default 128×128 grid, free heap at boot is ~28 KB — not enough for `esp_wifi_init` (needs ~16 KB RX buffers) after the light pipeline allocates ~210 KB. The device stays running but WiFi init fails silently. @@ -144,6 +174,10 @@ No FreeRTOS tasks are pinned today. At 16K LEDs the render task takes ~52 ms/tic ## Architecture +### Extract shared lane-driver scaffolding when the 3rd parallel backend lands (deferred) + +The LcdLedDriver (S3 LCD_CAM i80) and ParlioLedDriver (P4 Parlio) share ~245 of 362 lines, and their platform-side loopback capture+verify is ~100 lines byte-for-byte identical (`platform_esp32_parlio.cpp` even notes "The RX capture half is byte-for-byte identical" to the LCD one). The status-string lifecycle (`failBuf_` / `configErr_` / `clearFailBuf` / `clearConfigErr`) is triplicated across all three LED drivers (RMT/LCD/Parlio), ~60 lines. The branch deliberately extracted the *encoders* (`LcdSlots.h` shared by i80+Parlio, `RmtSymbol.h`, `PinList.h`) on the "extract when the second user lands" rule, but stopped at the lifecycle/loopback scaffolding. **Accepted for this merge** (the reviewer agreed driver-level extraction can wait): the duplication is in mechanical lifecycle/test scaffolding, not domain logic, and a DriverBase-level refactor touching three drivers is riskier than the duplication it removes. **Do it when the third parallel backend arrives** (16-lane widening, or Teensy FlexIO), at which point the pattern is proven three ways: (a) a `detail::` platform helper for capture+verify (the only per-peripheral difference is the transmit call, pass a callback, beside the already-shared `loopbackJumperOk`), and (b) a small owned-status helper or DriverBase members for the fail/config strings. Until then the cost is line count, not correctness. + ### Runtime board presets (multi-commit, partially landed) The firmware-vs-board separation is now in place across the codebase (see [architecture.md § Firmware vs board](../architecture.md#firmware-vs-board)). `build_esp32.py --firmware ` picks the compiled binary; MoonDeck deduces the physical board where the firmware uniquely identifies hardware (`esp32-eth*` ⇒ `olimex-esp32-gateway-rev-g`) and lets the user pick from a short hardcoded list otherwise. Firmware variants stay separate — `esp32-eth` saves ~670 KB flash + ~30 KB DRAM vs `esp32-eth-wifi` (measured); merging would erase that win. @@ -254,6 +288,36 @@ Step 3's custom RPC infrastructure is the seed. Plausible follow-on injectables: ## Sensors and audio-reactive input +### Audio-reactive follow-ups + +The manual level + 16-band FFT spectrum has shipped ([AudioModule](../moonmodules/core/AudioModule.md); what landed and why is in [decisions.md](../history/decisions.md)). These are the deferred follow-ups, each its own increment: + +- **Per-band noise-floor (kill a steady single-frequency hum)** — the bench mic picks up a constant ~258 Hz tone (a mains harmonic via the mic/supply) that lights one band even in silence. A high-pass can't remove it (it's well above the ~40 Hz DC-blocker cutoff) without also killing real bass; the clean fix is a per-band adaptive floor that learns each band's idle baseline and subtracts it, so a constant tone in one band gates to dark while the others stay sensitive. Minimal version ≈ 16 floats of state + ~16 ops/frame. This is the next concrete audio step. +- **Adaptive conditioning** — auto noise-floor / auto-gain / smoothing so the display self-calibrates to a room ("sound off → dark, sound on → vivid") instead of being tuned by hand. A self-calibrating version was prototyped and removed; the manual `floor`/`gain` is the shipped baseline. Reinvent from scratch when wanted, and **tune it in a quiet room** — a noisy environment (a strong, varying low-frequency ambient) is the adversarial case that made the prototype hard to settle. (The per-band floor above is the first piece of this.) +- **Adaptive noise gate** — replace the borrowed `squelch`/`floor`-as-gate with a real noise gate: asymmetric bang-bang timing (open fast, close slow), a relative "detect silence" test (thresholds as factors of a learned floor, not absolute sample counts), keying off the RMS envelope we already compute, GEQ/FFT bands left untouched. A softhack007 concept; analysed and judged in full (good idea, industry-standard, but tight on the <30ms budget; decompose into steps rather than overhaul) in [AudioModule.md § Adaptive noise gate](../moonmodules/core/AudioModule.md#adaptive-noise-gate-forward-looking). The recommended sequencing: the per-band floor above is step 1 (its complementary frequency-domain half), the relative-threshold-over-RMS is the cheap high-value cherry-pick as step 2, hysteresis/timing step 3, log-domain + soft-gate optional. Eventually retires the manual squelch. +- **Pin auto-scan** — detect the mic's `sdPin` with `wsPin`/`sckPin` fixed (a noise-prompt + confirm convenience); ships today with explicit pin controls. +- **Beat / onset detection** beyond the raw peak; more audio effects (2D / palette-driven frequency-reactive). + +### GyroDriver → core Peripheral move + AudioModule-consistency pass (branched, not merged) + +A working **GyroDriver** (MPU6050 IMU over I²C) exists on an unmerged branch (commit `11f8eb7`, "Add GyroDriver (MPU6050) + generic platform I2C layer"); it is not in this branch's tree. This entry reverse-engineers that commit so the move is tracked now. **Verify against the real implementation when the branch merges, then delete this entry.** + +What the commit contains (reverse-engineered): + +- `src/light/drivers/GyroDriver.h` — reads an MPU6050 over I²C and surfaces five read-only telemetry controls (`gyroX`/`gyroY`/`gyroZ` rates in °/s, `pitch`/`roll` tilt angles). Polls the sensor in `loop20ms()` (50 Hz), formats the display strings in `loop1s()`. WHO_AM_I probe + wake on `setup()`, big-endian 14-byte burst parse, `atan2`-based tilt (no fusion filter). +- A **generic, domain-neutral platform I²C master** (`platform::i2cInit`/`i2cWriteReg`/`i2cReadRegs`, 7-bit addressing) so future sensors reuse it; ESP32 impl on the IDF v6 `i2c_master` driver in a new `platform_esp32_i2c.cpp`, plus an MPU6050-shaped desktop simulation so the UI and host tests see live values without hardware. +- `unit_GyroDriver.cpp` — WHO_AM_I probe, simulated burst parse, control formatting, time-ramp tracking. + +The move: it currently masquerades as an input-only **driver** under the Drivers container (a no-op `setSourceBuffer(Buffer*) override {}` is the tell). It belongs as a **SystemModule Peripheral** child, exactly like [AudioModule](../moonmodules/core/AudioModule.md) — both are sensor peripherals that poll hardware and publish read-only telemetry. On the move, make it consistent with AudioModule (the established sibling pattern): + +- **Relocate** `src/light/drivers/GyroDriver.h` → `src/core/` and its spec `docs/moonmodules/light/drivers/GyroDriver.md` → `docs/moonmodules/core/`; change `role()` to `Peripheral`; delete the `setSourceBuffer` no-op; rewrite the doc's "input-only driver under the Drivers container" framing. +- **Pin controls + rebuild path.** GyroDriver hardcodes SDA/SCL (`static constexpr` 21/22, with its own "Hardcoded until BoardModule exposes I2C pin mapping" comment). AudioModule already shows the pattern: editable `uint16` pin controls + `controlChangeTriggersBuildState` + a `reinit()` on `onBuildState`. Adopting it retires the hardcoded-pins TODO and satisfies the robustness rule (reconfigure in any order). +- **Lifecycle.** GyroDriver has `setup()` only — no `teardown()`. Add teardown for symmetry with AudioModule's setup/teardown/reinit (the shared I²C bus has little per-instance state to free, so this is consistency, not a leak fix). +- **Document the cadence difference.** GyroDriver polls in `loop20ms()` (50 Hz is plenty for tilt); AudioModule reads in `loop()` every tick because I²S DMA must be drained promptly or it overflows. Both are correct; add a one-line "why this cadence" comment at each so the two siblings aren't "harmonised" into a bug. +- **Wire it** in `main.cpp` as a Peripheral child of System under `markWiredByCode`, the same shape as AudioModule. + +Already done on this branch (the reverse direction): AudioModule's two live read-outs were switched from `addText`+`setReadOnly` to `addReadOnly` (the display-only type, matching SystemModule and the way GyroDriver already does it correctly) — so the telemetry idiom is consistent before the gyro branch even lands. + ### Sensor input on Raspberry Pi 5 — microphone, IMU, line-in (post-1.0, multi-commit) Audio-reactive lighting (and motion-reactive) is core to what WLED-MM / MoonLight are known for. The Pi 5 is the right host for it: it has the CPU and RAM for real FFT-based audio analysis that the Xtensa ESP32 struggles with, and a full Linux audio + I²C stack. None of this exists today — the codebase has no sensor, audio, or IMU concept, and the Pi currently runs the **desktop** platform backend (there is no `src/platform/rpi/`), which has no hardware access. So this is a domain expansion built on a real platform-backend prerequisite, not a small add. @@ -305,7 +369,7 @@ What to build (~4 h): ### Add real z-axis variation to 2D effects (pending) -Only **NoiseEffect** and **PlasmaEffect** have z-aware math. The other 10 effects are honest D2 — `Layer::extrude` duplicates the z=0 plane, so every z-slice is identical on 3D layers. Candidates for genuine D3 promotion: Metaballs/GlowParticles (add z to blob coordinates), Plasma palette/Spiral (add z-driven phase term), Fire (z-drift heat grid), Ripples/LavaLamp/Checkerboard/Particles (add z to each element). Prioritise after seeing real 3D installations; each promoted effect also needs its `dynamicBytes` budget for the full 3D buffer. +Only **NoiseEffect**, **PlasmaEffect** and **RipplesEffect** have z-aware math. The other honest-D2 effects use `Layer::extrude` to duplicate the z=0 plane, so every z-slice is identical on 3D layers. Candidates for genuine D3 promotion: Metaballs/GlowParticles (add z to blob coordinates), Plasma palette/Spiral (add z-driven phase term), Fire (z-drift heat grid), Rings/LavaLamp/Checkerboard/Particles (add z to each element). Prioritise after seeing real 3D installations; each promoted effect also needs its `dynamicBytes` budget for the full 3D buffer. ### Full-density interpolated preview for large layouts (backlog) @@ -323,9 +387,10 @@ Not simple — own planning pass. Until then the preview is a faithful strided * ### Additional test coverage (pending) +- **RipplesEffect in the AllEffects sweep** — `scenario_AllEffects_grid_sizes.json` measures 14 effects but not `RipplesEffect` (added after the sweep was written; its description now says so explicitly). Add a 4-grid-size measurement block mirroring its sibling `RingsEffect` (~300 lines of scenario JSON), then a live run to populate the per-target `observed` blocks. Cheap but mechanical; do it with a device attached so the observations are real, not zero-filled. - **UI page load time** — scenario step measuring HTTP response time for `/`, `/api/state`, `/api/system` via the live runner. Verifies acceptable load time on ESP32. - **Module teardown memory** — scenario that tears down all modules and verifies heap returns to pre-setup baseline. Confirms no lifecycle leaks. -- **JavaScript test harness** — `vitest` or `node --test` with `jsdom` for pure helpers in `install-picker.js` (`isCompatible`, `parseFirmwaresFromAssets`, `relativeTime`). Deferred until a second non-trivial JS module lands — one file doesn't justify the toolchain weight. +- **JavaScript test harness** — `vitest` or `node --test` with `jsdom` for pure helpers in `install-picker.js` (`isCompatible`, `parseFirmwaresFromAssets`, `relativeTime`) **and `app.js`'s conditional-control DOM logic** (`syncVisibleControls` — reconciles which control rows are rendered when a `hidden` flag flips). The C++/backend half of conditional controls IS unit-tested (`conditional_controls.h` + per-module tests pin the binding + `hidden` flag), but the **UI re-render half is not** — `syncVisibleControls` was the source of a real re-render-loop freeze (Network static-IP toggle) caught only on hardware. A `jsdom` test that builds a card, flips a control's `hidden`, runs `updateValues`, and asserts the right rows appear/disappear (and that it converges — doesn't re-render every tick) would have caught it. This is now the **second non-trivial JS module** the deferral was waiting for, so the toolchain is more justified than before. - **Browser-level Improv automation** (deferred) — `scripts/build/improv_smoke_test.py` (added 2026-06-03) exercises the device-side Improv listener over plain serial; what's missing is the browser-side equivalent — Playwright driving Chrome's Web Serial, clicking through ESP Web Tools' install modal, filling the WiFi creds form, asserting `PROVISIONED`. Catches "ESP Web Tools changed its Improv handling in a way that broke our manifest format" failures the serial-only smoke test can't see. Hard to set up reliably (headless Chrome with Web Serial is finicky, needs a wired ESP32 in CI). Pick this up if a regression in the browser flow ever escapes the manual dev-environment test (preview_installer flash-ready mode at ). ### Live full-suite run leaks state between scenarios (test infra) @@ -344,7 +409,48 @@ Fix options: (a) make every live mutate scenario clear+rebuild its own canvas (c ### ESP-IDF version pinning (pending) -Check whether `setup_esp_idf.py` pins to a specific commit/tag or always pulls latest. If latest, running "Setup ESP-IDF" in MoonDeck silently changes the IDF version and may break the build. Pin to the tested version (`v6.1-dev-399-gd1b91b79b`) or document that updates require re-testing. +The build IDF is `v6.1-dev-399-gd1b91b79b5`, a dev-branch snapshot (2025-11-05) ahead of the v6.0 stable but on the unreleased v6.1 line. The version facts (what v6.0 vs v6.1 changed, the release schedule, the 30-month support policy, how to check for a newer tag) live in [building.md § ESP-IDF version](../building.md#esp-idf-version); this entry tracks only the **open decisions** the doc doesn't make. Being on a dev branch already cost us once — the missing `ESP_ROM_ELF_DIR` in the post-build gdbinit step (fixed in `build_esp32.py`). **Partly landed:** `setup_esp_idf.py` carries `PINNED_IDF_COMMIT`/`PINNED_IDF_VERSION` and **warns on drift** (installed HEAD vs pinned) — it can't `checkout` for you (it doesn't own the clone), but a silent `git pull` or a stray shallow clone is now visible. **Still to do:** (a) a MoonDeck UI banner / status dot surfacing the same drift (the CLI warning only shows during Setup), and (b) the migrate-or-stay call — stay on the pinned commit (chosen for now: it's what all targets incl. P4 were validated against), or move to `v6.1` stable (skipping v6.0, since v6.1 is close); migration is a full re-validation pass across classic/S3/P4, a deliberate task, not a pull. Until then: don't `git pull` the IDF. **Schedule note:** the v6.1-stable target of 2026-07-31 is unlikely to hold — v6.0 slipped ~1 month (planned 2026-02-27, shipped late March), and Espressif minors historically slip 2-6 weeks on the *final* even when betas land on time. So migrate **to the event** (v6.1 stable actually tagging on the releases page), not to the calendar date. `v6.0` stable is the lower-risk fallback if the dev-branch warts (`ESP_ROM_ELF_DIR`, API-churn risk) get worse before v6.1 lands. + +### Three-level device model: MCU → Board → Device (config provenance) + +A first-class hierarchy for *where a pin default legitimately comes from*, surfaced through the installer and MoonDeck so a user picks their hardware instead of hand-typing every GPIO. Three levels, each a layer that may fix some settings: + +- **MCU → firmware.** The chip (esp32 classic / S3 / P4). Fixes the silicon-wired pins: the RMII **Ethernet** pin map, native-radio presence, PSRAM. These are chip-facts — already today's compile-time `platform::ethPins` / `hasI2sMic` / `hasWiFi` constants in `platform_config.h`. The firmware variant IS the MCU choice. +- **Board → MCU on a PCB.** e.g. Wemos D1-mini, **Waveshare ESP32-P4-Nano** (https://www.waveshare.com/esp32-p4-nano.htm). Fixes board-soldered pins: the onboard status LED, the board's own Ethernet PHY pins, the C6 co-processor SDIO pins, button pins. A board is a named bundle of "these GPIOs are already committed by the PCB." Carry a board catalog (id, name, image, **product link next to the image**, the pins it fixes). +- **Device → board + peripherals.** board + what the *user* soldered on: the mic, the LED strands, a 4↔5 rxtx loopback jumper. These are **user choices** — the same board yields different devices. A device is a saved profile of Device-level pin assignments the user confirmed once. + +**The provenance rule applies to *settings*, not just pins — `txPowerSetting` is the worked example.** WiFi TX power is a Device-level setting, not MCU or Board: the radio chip can do the full 21 dBm (an MCU fact), but whether the assembled rig can *sustain* the current spike of full-power TX depends on the board's power regulation **and how the user powers it** (USB port, PSU, cable) — a brownout property of the whole device, not the silicon. Confirmed in-tree: [`boards.json`](../install/boards.json) injects `"Network": { "txPowerSetting": 8 }` for the **LOLIN S3 N16R8** specifically (the chip's default is 21), because that board browns out at full power on typical USB supply — a per-board floor the user may still need to lower further on a weak supply. So in the model, a board profile *suggests* a safe `txPowerSetting` (like it suggests board-fixed pins), and the device profile can override it for the actual power source. The general lesson: any setting whose safe value depends on the physical assembly or its power, not on the chip, defaults at the Device level under the same "default only when it cannot do harm" rule. + +**Why this is the right model — and it's the runtime form of the pin-default rule we just landed.** The rule "**assign defaults only when they cannot do harm**" ([decisions.md](../history/decisions.md)) is exactly "**a pin may be defaulted only at the level that actually fixes it.**" MCU- and Board-level pins are hardware-fixed → a default cannot do harm (and *omitting* it harms: a no-WiFi board with un-defaulted Ethernet pins can never connect). Device-level pins are user-soldered → any default is a guess that *can* drive a pin the user wired elsewhere → must stay empty until set. So the empty Device-level defaults shipping now (AudioModule mic pins, the LED-driver pins) are the **correct baseline a device profile layers onto** — a profile *fills* blanks from confirmed hardware, it doesn't *override* a wrong guess. Had we kept bench defaults, every profile would start by undoing a guess. This backlog item is the natural home for those Device-level values: not hard-coded, but picked. + +**Installer + MoonDeck support (the deliverable).** The web installer's board picker (`docs/install/devices.js`, `src/ui/install-picker.js`) grows from "which firmware" to "which **board**" (auto-selecting the firmware/MCU), with product links beside each board image. MoonDeck gains a device-profile concept: save/restore the Device-level pin set for a named device, so re-flashing or adding a second identical rig doesn't re-type GPIOs. `moondeck.json` (the device registry) is the obvious store. Sequencing: the **Distribution → Runtime PHY/pin config** item above is the MCU/Board-level prerequisite (runtime Ethernet-pin selection); this item is the Device-level layer on top. Own branch — not now. + +### ESP32-P4 support — rounds 3-4 (in progress) + +Rounds 1 (board + Ethernet-only) and 2 (Parlio LED driver) have landed. Remaining rounds, each its own plan + commit: +- **Round 3 — WiFi via the C6 co-processor. PARTIALLY PROVEN — C6 link up, STA failover not yet working.** The P4 has no native radio (`SOC_WIFI_SUPPORTED` absent); WiFi comes from the on-board ESP32-C6 over SDIO via `esp_wifi_remote` / esp_hosted. Landed as the `esp32p4-eth-wifi` firmware variant: components pulled P4-only (`rules:` gate in `idf_component.yml`), and `ensureWifiInit()` adds an `esp_hosted_init` + `connect_to_slave` prelude before `esp_wifi_init` (gated on `platform::usesRemoteWifi`). The rest of the WiFi seam is unchanged because `esp_wifi_remote` is API-compatible. A deliberate, documented [v6.0-floor exception](../building.md#esp-idf-version); C6 config via `CONFIG_SLAVE_IDF_TARGET_ESP32C6` + `CONFIG_ESP_HOSTED_CP_TARGET_ESP32C6` + the `CONFIG_ESP_HOSTED_P4_DEV_BOARD_FUNC_BOARD` SDIO-pin preset. + + **Hardware results (bench, P4-NANO, 2026-06-12):** + - ✅ **esp_hosted / C6 SDIO comes up at boot.** `host_init: ESP Hosted`, `H_API: ESP-Hosted starting`, `add_esp_wifi_remote_channels`, `H_SDIO_DRV: sdio_data_to_rx_buf_task started`. No NVS error / assert / panic / hang. Device boots fully (~57-60 FPS), `hasWiFi` true, WiFi controls present. esp_hosted **self-initialises at boot via a constructor** (`ESP_SYSTEM_INIT_FN` → `esp_hosted_init`), so no bring-up code is needed in our platform layer — an earlier explicit `esp_hosted_init` + `esp_hosted_connect_to_slave` prelude was *removed*: init was a redundant no-op and `connect_to_slave` is actually a transport *reconfigure* (slave GPIO-54 reset + SDIO re-init). SDIO config confirmed correct on the wire: `CLK[18] CMD[19] D0[14] D1[15] D2[16] D3[17] Slave_Reset[54]`, 4-bit 40 MHz. + - ❌ **WiFi STA connect fails on the SDIO re-init.** The cascade DOES fire correctly (`Ethernet no link, cascading` → STA path), but `esp_wifi_init()` (forwarded to esp_wifi_remote) internally triggers `esp_hosted_reconfigure` → `Reset slave using GPIO[54]` → **`sdmmc_card_init failed` (×15) → `card init failed` → `esp_wifi_init failed: ESP_FAIL`**. So: the boot-time SDIO init succeeds, but a **runtime slave reset can't re-establish the SDIO link**. The C6 doesn't come back after the GPIO-54 reset during operation. This is an esp_hosted/SDIO/C6-slave-firmware level issue (reset timing or slave image), below our application code — the pins and Kconfig are correct. + + **Open issues before this is done:** + 1. **Runtime SDIO re-init of the C6 fails — CONFIRMED a C6 slave-firmware problem (not a guess).** SystemModule now exposes a `wifiCoproc` read-only control (via `platform::coprocessorWifi()` → `esp_hosted_get_coprocessor_fwversion()`), and on the bench it reads **`not detected`** — the C6 returns no valid firmware version (0.0.0 / handshake never completes), which is exactly the documented signature of absent / incompatible C6 slave firmware. So this is proven off the device, not inferred. Likely a version mismatch on top of that: The host pulled esp_hosted **2.12.9**; Espressif's P4-Function-EV-Board ships its C6 pre-flashed with esp_hosted slave **v0.0.6**, and the **Waveshare NANO is a different board that may carry a different / absent C6 slave image**. The symptom fits: boot inits the host SDIO master fine, but resetting the C6 (GPIO 54) and re-enumerating it as a slave fails (`sdmmc_card_init failed`) because the C6 has no compatible slave firmware responding. **Primary next step: build + flash the version-matched esp_hosted slave firmware onto the NANO's C6.** The slave project is already vendored at `esp32/managed_components/espressif__esp_hosted/slave/` (`sdkconfig.defaults.esp32c6`, `partitions.esp32c6.csv`); `idf.py create-project-from-example "espressif/esp_hosted:slave"` → `set-target esp32c6` → flash. **Caveat / needs PO + bench hardware:** flashing the C6 on the EV board uses an **ESP-Prog wired to the `PROG_C6` header** with the P4 held in bootloader mode (esp_hosted `docs/esp32_p4_function_ev_board.md` §5.2) — the NANO's C6-flash path must be confirmed (separate USB? equivalent header? ESP-Prog?), and an ESP-Prog may be needed. An OTA slave-update path exists but needs a *working* link first (chicken-and-egg here). This is a hardware-provisioning task, not application code. Secondary fallbacks if firmware-match doesn't fix it: an esp_hosted option to skip the reconfigure/slave-reset when the transport is already up at boot; a slower SDIO freq or 1-bit mode; verify GPIO 54 reset polarity/timing for the NANO. **(Note: EIM — the building.md v6.0-adoption item — does NOT help here; it's a host-machine installer, unrelated to device-side C6 firmware.)** + 2. **Co-processor components compile into `esp32p4-eth` (the WiFi-less P4) at build-time only.** The `rules: target == esp32p4` gate in `idf_component.yml` pulls `esp_hosted` / `esp_wifi_remote` / `eppp_link` / `wifi_remote_over_eppp` for *any* esp32p4 build (manifest rules can't see our eth-only flag), and `EXCLUDE_COMPONENTS` does not drop a managed dependency. Impact is **build-time only**: the linker dead-strips them (their `.text` is `0x0` in the eth-only `.map`, because `coprocessorWifi()` is the empty stub there), so **~0 flash bytes** are added to `esp32p4-eth`. Left as-is. If the build-time cost matters later, the fix is a manifest-level conditional the component manager doesn't currently expose simply, or a separate component set per firmware. The `wifiCoproc` read-out itself is compile-gated on `platform::hasWifiCoprocessor` (= `isEsp32P4 && hasWiFi`), so on classic/S3/desktop/eth-only-P4 the control, its buffer fills, and the esp_hosted query all vanish via `if constexpr` (verified: classic `esp32` build pulls no esp_hosted). + 3. **Build reproducibility.** `build_esp32.py` does not yet build this variant reliably: the C6 slave-target Kconfig `default ... if IDF_TARGET_ESP32P4` only fires on `set-target`, and the reconfigure a plain `build` triggers drops it back to ESP32-H2 (no WiFi) → fails on missing `CONFIG_WIFI_RMT_*`. A clean manual sequence works (`rm -rf ` → `set-target esp32p4` → `build`, all with the same `-DSDKCONFIG`/`-DSDKCONFIG_DEFAULTS`); the wrapper needs a fix so the auto-default sticks across reconfigures (see the KNOWN ISSUE comment in `build_esp32.py`). +- **Round 4 — Parlio loopback + real strip.** A Parlio rx/tx (or RMT-RX-captured) loopback self-test like the RMT/LCD ones, then a real WS2812 strip proven on hardware. + +### Drop the i80 WR/DC sacrificial pins (S3 LcdLedDriver) via direct LCD_CAM + +The S3 i80 LED path costs **two GPIOs the LEDs never use**: the IDF `esp_lcd` i80 bus hard-requires a WR (pixel clock) and a DC pin on real GPIOs (`esp_lcd_panel_io_i80.c`: `wr_gpio_num >= 0 && dc_gpio_num >= 0`), even though WS2812 strands ignore both. Today `LcdLedDriver` keeps overridable defaults (clockPin=10, dcPin=11) — peripheral-required, not user-strand wiring, so a default cannot do harm. **Two ways to reclaim the pins, neither trivial:** +- **Cannot reuse a data pin for WR/DC.** A GPIO carries exactly one peripheral signal (`esp_rom_gpio_connect_out_signal` binds data_sig[i] / wr_sig / dc_sig each to its own pin); routing WR onto a data lane would clock the *clock* waveform onto that strand instead of its colour bytes. WR/DC must be distinct *physical* pins from the 8 data pins. (You CAN already point them at any otherwise-free or unstrapped GPIO via the controls — that's the "reuse a pin you're not using" answer; it's the *spare* pin you avoid, not a data pin.) +- **Zero WR/DC pins needs bypassing esp_lcd** and driving the LCD_CAM peripheral's registers directly (hpwit's I2SClockless approach — legacy parallel mode has no DC concept and emits WR without a dedicated config pin). That's the only path to 8-pins-total on the S3. Cost: leaving the recognisable IDF `esp_lcd` API for register-banging (a *Common patterns first* hit), re-proving the driver bit-perfect on hardware (the loopback self-test is the proof). Benefit: 2 GPIOs back on a tight S3 board. Its own increment, not a pin-default tweak. Parlio (P4) already needs no extra pins (`clk_out_gpio_num = GPIO_NUM_NC`), so this is S3-i80-only. + +### LCD/Parlio DMA frame buffer → PSRAM (free internal SRAM for big frames) + +For driving **lots of LEDs**, internal SRAM is the scarce resource and the parallel-driver DMA frame buffer is the biggest consumer (8 lanes × lights × outCh × 24 slot-bytes + latch pad). Today both parallel drivers allocate it as `MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL` (`platform_esp32_lcd.cpp`, `platform_esp32_parlio.cpp`) — **internal SRAM only**, so a large frame can exhaust DRAM while PSRAM sits unused. The IDF confirms both peripherals' GDMA **can burst straight from PSRAM** on the S3/P4: `esp_lcd_panel_io_i80.c` sets `access_ext_mem = true` and itself allocates the buffer with `MALLOC_CAP_SPIRAM | MALLOC_CAP_DMA` when asked; `esp_driver_parlio/src/parlio_tx.c:158` sets `access_ext_mem = true // support transmit PSRAM buffer`. (RMT already does the right thing — its symbol buffer goes through `platform::alloc`, which is PSRAM-first with an internal fallback.) + +**The change:** allocate the LCD/Parlio buffer `MALLOC_CAP_DMA | MALLOC_CAP_SPIRAM` first, falling back to internal when PSRAM is absent/full, using the **external-memory alignment** the IDF requires (`gdma_get_alignment_constraints` → `ext_mem_align`, typically the cache line — larger than the current 64-byte internal alignment) and keeping the buffer cache-aligned + its size a multiple of that alignment. **Why it's its own increment, not this commit:** it changes the proven hot DMA path, PSRAM DMA has real caveats (cache-line alignment, write-back/coherence on the encode→DMA handoff, and lower PSRAM bandwidth that the IDF guards with a CPU-MAX DFS lock during transmit), and it **must be re-proven on S3 + P4 hardware** (the loopback self-test bit-verifies it, then a real strip). Measure the bandwidth headroom too: a very wide, long frame at speed may want internal SRAM regardless. Scope: the two `heap_caps_aligned_alloc` sites + their `bufferBytes` alignment rounding + the capacity check; no domain-code change (the encode loop already writes through `dmaBuf_`). ### WiFi runtime disable (backlog) diff --git a/docs/backlog/leddriver-increment-1-plan.md b/docs/backlog/leddriver-increment-1-plan.md index 6590e0fd..91b81a6f 100644 --- a/docs/backlog/leddriver-increment-1-plan.md +++ b/docs/backlog/leddriver-increment-1-plan.md @@ -2,7 +2,7 @@ The first concrete LED-driver increment, derived from [leddriver-analysis-top-down.md](leddriver-analysis-top-down.md) (architecture, protocol math, per-platform peripheral choice, test strategy) and [leddriver-analysis-bottom-up.md](leddriver-analysis-bottom-up.md) (build-vs-borrow, performance budget, interface sketch). This file is the *plan*; the analysis docs are the *why*. Forward-looking — exempt from the present-tense rule like the rest of `backlog/`. -**Status:** planned, not started. Deferred until the board-catalogue / firmware-variant work (PR #16: chip detection, n8r8, 16mb) lands first. +**Status:** Phase A (contract) locked; implementation starting on branch `led-driver-rmt`. The board-catalogue / firmware-variant work (PR #16) merged. Methodology: **contract-first → test-first (red) → implement-to-green** — the proof of correctness is written as failing assertions *before* the driver, so "it works" is a red→green transition on an independent test, not an opinion. Three proof tiers: host encoder test (CI, every push), on-device RMT-RX loopback (real silicon), sigrok-under-WiFi flicker test (later, validates the core-1 fix against its own failure mode). ## Locked decisions (product owner, this planning session) @@ -11,31 +11,41 @@ The first concrete LED-driver increment, derived from [leddriver-analysis-top-do | First peripheral | **RMT on classic ESP32**, single strand, WS2812B, 8-bit, synchronous | Smallest/most-likely-to-work, and RMT is the only TX+**RX** peripheral → enables zero-hardware on-board loopback tests that every later driver reuses. Not I2S/LCD_CAM (those are TX-only, higher complexity, the right *second* step for parallel strands). The RMT-flickers-on-WiFi reputation is the DMA-less v2 default at scale, not a hello-world single-strand problem ([analysis §2.1](leddriver-analysis-top-down.md)). | | Driver hierarchy | **One `DriverBase`, three children — no `PhysicalDriverBase` middle layer** | Product owner rejected a 3-level hierarchy. Logically there is one kind of thing ("a driver") with N variants (preview, artnet, led), not a "physical" sub-kind worth a class. | | Framing | Each driver **interprets the light preset for its output medium** — Preview is a *peer interpreter*, not a "correction opt-out" | Surfaced by the moving-head question: a moving-head preview must understand channel *semantics* (pan = ch5, tilt = ch6, RGBW at arbitrary indices) — the same light-preset info physical drivers use, interpreted to pixels-on-screen instead of bytes-on-wire. So "physical applies correction, Preview opts out" is the wrong concept; "every driver interprets the preset" is right. Name the abstraction this way now even though moving heads are far off, so nothing locks in "Preview = opt-out" language. | -| Shared-code home | Correction / light-preset machinery in `DriverBase`; ArtNet + LED share the correct+encode path (**ArtNet sheds ~30 lines** — the subtraction checkpoint). Preview reads the same state, interprets its own way. | One base, no duplication, no extra layer. | +| Shared-code home | **Share `Correction::apply()` only — do NOT extract a shared correct-loop yet, and do NOT change ArtNet.** (Phase-A revision of the earlier "ArtNet sheds ~30 lines" idea.) The loop *around* `apply()` differs irreducibly: ArtNet correct into a contiguous byte buffer then chunk into universes; LED must correct **and** symbol-encode in one pass (RMT consumes `(level,duration)` symbols, not contiguous bytes). Extracting now would produce a base method ArtNet uses one way and LED literally can't use — an abstraction with one real user. The LED `loop()` instead reads as a **recognizable sibling** of `ArtNetSendDriver::loop()` (same guard pattern, same `correction_->apply()` call, same once-allocated owned buffer, different inner emit) — that recognizability *is* the reuse. | `Concrete first, abstract later`. Extract a templated per-light helper only when the **second fused driver** (LCD_CAM on S3, which also does correct+transpose-encode in one pass) lands and proves the shape — two concrete users, not one guess. Avoids the premature-unification trap the analysis docs caught other repos in (hpwit's `loadAndTranspose` duplicated under `#ifdef` because it was abstracted before the variants were understood). | | Platform seam | The **encode** (bit→RMT-symbol, MSB-first; GRB already applied by `Correction`) is **domain logic in `src/light/`**; the platform owns only peripheral init + DMA + show + reset. The one coupling (RMT symbol bit-layout) is a documented constant in a shared header so no ESP driver header leaks into `src/light/`. | "Platform = really the hardware stuff." Also lets the host CI encoder test exercise the encode with no ESP32. | | Execution model | **Inline synchronous `show()`** on the render tick (~3 ms for 100 px) for increment 1; dedicated core-1 driver task + per-module core-affinity control deferred to a later increment | Minimalism — a single 100 px strand doesn't need the WiFi-mitigation task machinery yet ([analysis §7.2](leddriver-analysis-top-down.md) is the future shape). | | `gpio` control | **Live re-init** (deinit + reinit RMT on change), not reboot-to-apply | Consistent with the rest of the live-editable UI; reads as a better example. | | Test harness | **On-board RMT-RX loopback first** (jumper TX→RX, decode pulses→bytes, assert) + a **host encoder unit test** that runs in desktop CI. sigrok/fx2lafw cross-check and the MoonDeck script are **later increments**. | Loopback is the $0 CI-able correctness proof; sigrok is the independent-clock cross-check on top. | -## File-by-file (ordered; each step gates on its tests) +## File-by-file (contract → test-first → implement-to-green) -1. **`src/platform/esp32/platform_config.h` + `src/platform/desktop/platform_config.h`** — add `constexpr bool isEsp32` / `isEsp32S3` (don't exist yet), set per `CONFIG_IDF_TARGET_*`; both false on desktop. -2. **`src/platform/platform.h`** — add the RMT seam declarations (`rmtWs2812Init/Resolution/Show/Deinit`, `rmtWs2812RxCapture`). **`platform_desktop.cpp`** — no-op stubs so the build stays green everywhere (driver guards every call with `if constexpr (platform::isEsp32)`). -3. **`DriverBase` (in `src/light/drivers/Drivers.h`)** — absorb the shared correct-loop + corrected buffer + sizing (moved out of ArtNet). Keep Preview able to skip it. -4. **`src/light/drivers/ArtNetSendDriver.h`** — migrate onto the shared base; behaviour-preserving (its unit + scenario tests must stay green — **subtraction checkpoint**: ArtNet loses ~30 lines). -5. **`src/light/drivers/RmtSymbol.h`** (new) — pure `encodeWs2812Symbols(wire, channels, t0hTicks, t1hTicks, periodTicks, out*)`, MSB-first, no platform include. **`test/unit/light/unit_RmtLedEncoder.cpp`** (new) — host CI test: GRB order, MSB-first, T0H/T1H widths in symbol ticks, RGBW → 32 symbols, N lights → N×ch×8 symbols. Add to `test/CMakeLists.txt`. -6. **`src/light/drivers/RmtLedDriver.h`** (new, header-only) — the readable example. `gpio` control (live re-init), fused per-light `Correction::apply` + `encodeWs2812Symbols` in one pass into a once-allocated symbol buffer, calls `platform::rmtWs2812Show`. WS2812B/8-bit/single-strand only. -7. **`src/platform/esp32/platform_esp32_rmt.cpp`** (new) — RMT TX (+ RX for loopback), `IRAM_ATTR` ISR, channel/encoder allocated once in init (never in show). Resolution ~40 MHz (25 ns/tick) so 350/700/1250 ns → 14/28/50 ticks; ns→ticks via the granted resolution, not hard-coded. -8. **`src/main.cpp`** — register `RmtLedDriver` type; add as a `Drivers` child under `if constexpr (mm::platform::isEsp32)`. -9. **`docs/moonmodules/light/drivers/RmtLedDriver.md`** (new) — required by `check_specs.py` (it has a `gpio` control). Controls, WS2812B timing, shared-Correction note, "synchronous show blocks the render tick" + core-1-later limitation, test links, prior art. -10. **`docs/architecture.md` § Drivers** — record the unified contract: all drivers share `DriverBase`'s preset interpretation; drop the "Preview is exempt" framing in favour of "each driver interprets the preset for its medium." -11. **`test/device/device_RmtLoopback.cpp`** (new dir + file, hardware-gated) — TX a known buffer, RX-capture on the jumpered pin (GPIO4→GPIO5), decode, assert equal. Establishes the `test/device/` convention for hardware-only tests. +Phase A revised the original order: **no `DriverBase` extraction, no ArtNet migration** (share `Correction::apply()` only). And the methodology is **test-first** — the encoder test is written red (Phase B) *before* the encoder exists (Phase C), so success is a proven red→green transition. + +**Phase A — contract (no behaviour, host-compilable):** +1. **`src/light/drivers/LedDriverConfig.h`** (new) — pure-data `LedDriverConfig { t0h_ns=350, t1h_ns=700, period_ns=1250, reset_us=300, invert=false }`. No platform include; host-testable. +2. **`src/light/drivers/RmtSymbol.h`** (new) — the contract's encode declaration: pure `encodeWs2812Symbols(wire, channels, t0hTicks, t1hTicks, periodTicks, out*)`, MSB-first, no platform include. The RMT symbol bit-layout (`(duration:15,level:1)` halves packed in a `uint32_t`) is a documented constant here so no ESP header leaks into `src/light/`. **Declared/stubbed first so the test can compile and FAIL.** + +**Phase B — write the proof red (CI tier):** +3. **`test/unit/light/unit_RmtLedEncoder.cpp`** (new) + `test/CMakeLists.txt` — the success spec as assertions: a known logical buffer + light preset → exact symbol stream (GRB order; MSB-first; "0" bit HIGH=`t0hTicks` then LOW=`period-t0h`; "1" bit HIGH=`t1hTicks`; RGBW preset → 32 symbols/light; N lights → N×ch×8 symbols). **Runs red** against the stub from step 2. This defines "correct." + +**Phase C — implement to green:** +4. **`src/light/drivers/RmtSymbol.h`** — implement `encodeWs2812Symbols` until step 3 goes green. *This is the CI proof of correctness.* +5. **`src/platform/{esp32,desktop}/platform_config.h`** — add `constexpr bool isEsp32` / `isEsp32S3` per `CONFIG_IDF_TARGET_*`; both false on desktop. +6. **`src/platform/platform.h`** — RMT seam declarations (`rmtWs2812Init/Resolution/Show/Deinit`, `rmtWs2812RxCapture`). **`platform_desktop.cpp`** — no-op stubs (driver guards every call with `if constexpr (platform::isEsp32)`), so desktop + CI stay green. +7. **`src/light/drivers/RmtLedDriver.h`** (new, header-only) — `RmtLedDriver : public DriverBase`. A recognizable sibling of `ArtNetSendDriver`: same guard pattern, same `correction_->apply()` per light, but fuses apply + `encodeWs2812Symbols` into a once-allocated symbol buffer, then `platform::rmtWs2812Show`. `gpio` control (live re-init). WS2812B/8-bit/single-strand. `inputBitsPerChannel()` returns 8 (stub hook for later 16-bit/dither). +8. **`src/platform/esp32/platform_esp32_rmt.cpp`** (new) — RMT TX (+ RX for loopback), `IRAM_ATTR` ISR, channel/encoder allocated once in init (never in show). Resolution ~40 MHz (25 ns/tick) → 350/700/1250 ns = 14/28/50 ticks; ns→ticks via the granted resolution, not hard-coded. +9. **`src/main.cpp`** — register `RmtLedDriver`; add as a `Drivers` child under `if constexpr (mm::platform::isEsp32)`. + +**Phase D — HAL proof (hardware tier) + docs:** +10. **In-firmware loopback self-test** — the end-to-end HAL proof, run from the *normal* firmware via RmtLedDriver's `loopbackTest` control (no separate test build): `platform::rmtWs2812Loopback(txGpio, rxGpio)` TX a known pattern, RX-capture on the jumpered pin, decode, compare; the result goes to the MoonModule status field. (Started as a standalone `--loopback-test` firmware + `device_RmtLoopback.cpp`; that was deleted in favour of the control-driven self-test, which avoids a separate-firmware footgun and makes the diagnostic available to any user.) +11. **`docs/moonmodules/light/drivers/RmtLedDriver.md`** (new, required by `check_specs.py` — has a `gpio` control) + **`docs/architecture.md` § Drivers** (record "each driver interprets the preset for its medium"; the new RmtLedDriver as the readable sibling example). ## Open items deferred to later increments - **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)). - **Parallel strands via LCD_CAM (S3) / I2S (classic) / PARLIO (P4)** — increment 2+; reuse this increment's `DriverBase` contract, encoder helpers, and test rig. The fused per-light apply+encode loop becomes a templated base helper when the *second* fused driver lands (don't extract speculatively). - **Dedicated core-1 driver task + per-module core-affinity control** ([analysis §7.2](leddriver-analysis-top-down.md)). +- **`rmtWs2812Show` fuller error handling** (deferred from PR #17 / 🐇 CodeRabbit). Increment 1 gives it a finite `rmt_tx_wait_all_done` timeout (1 s) so a wedged DMA can't hang the render tick forever; a dropped frame self-heals because the driver re-encodes the whole frame next tick. The fuller version — `rmt_transmit` return check, `rmt_tx_stop` to cancel an in-flight transfer on timeout, `show()` returning failure so `RmtLedDriver::loop()` won't reuse `symbols_` mid-transmit — belongs with the **core-1 driver-task** work, since that increment reworks this whole synchronous path (the driver task owns the buffer lifetime and the in-flight state the cancel logic needs). **Flicker observation (2026-06):** a single transient flicker was seen once on the bench panel; most likely the 3.3 V data line into 5 V WS2812 (no level shifter — marginal logic-high), possibly a render-core interrupt mid-show (which the core-1 task fixes). One-off, unconfirmed — recorded so the sigrok sustained-capture flicker test (above) has a data point to chase, and as a reminder that a level shifter / ~4.3 V panel power is the first thing to rule out. - **Auto-derived DMA buffer count** (7 / 30 / 75 per [analysis §7.4](leddriver-analysis-top-down.md)), 16-bit pipeline + dither ([§7.3](leddriver-analysis-top-down.md)), shift-register expander stubs ([§7.5](leddriver-analysis-top-down.md)). - **Moving-head preview = peer interpreter.** When moving heads land, the previewer must interpret channel semantics (pan/tilt/RGBW-at-arbitrary-indices) to render a moving fixture — the same light-preset model physical drivers use, interpreted to screen. This is *why* increment 1 names the abstraction "interpret the preset" rather than "apply correction / opt out": so Preview becomes a full peer here without a rename. Its own design plan when moving-head support starts. diff --git a/docs/backlog/leddriver-increment-2-plan.md b/docs/backlog/leddriver-increment-2-plan.md new file mode 100644 index 00000000..8bbca291 --- /dev/null +++ b/docs/backlog/leddriver-increment-2-plan.md @@ -0,0 +1,32 @@ +# LED driver — increment 2 plan (multi-pin RMT, then parallel LCD_CAM on S3) + +The second LED-driver increment, building on [leddriver-increment-1-plan.md](leddriver-increment-1-plan.md) (RMT single-strand, shipped on branch `led-driver-rmt`) and the analysis docs ([top-down](leddriver-analysis-top-down.md) for peripheral choice and WiFi coexistence, [bottom-up](leddriver-analysis-bottom-up.md) for the build-vs-borrow survey). This file is the *plan*; the analysis docs are the *why*. Forward-looking — exempt from the present-tense rule like the rest of `backlog/`. + +**Status:** decisions locked (product owner, planning session 2026-06-10): counts via a parallel `ledsPerPin` list (not even-split-only); per-driver buffer window **deferred** out of 2a; bench board is the **LOLIN S3 N16R8**; sequence **2a → 2b** as proposed. **2a is implemented** on branch `led-driver-rmt` (multi-pin `RmtLedDriver`, S3 enabled via SOC capability constants, transmit-all/wait-all platform seam) — pending hardware test. **2b is implemented** (`LcdLedDriver`, 8 lanes via the esp_lcd i80 bus, product-owner choice 2026-06-10) with two recorded deviations from the sketch below: (1) the **core-1 driver task is deferred again** — the implementation pre-encodes the whole frame into one DMA buffer and ships it as one autonomous GDMA transfer, so no refill deadlines exist for WiFi to miss (the failure mode the task was meant to mitigate); the task returns when an install outgrows internal DRAM (~1500+ lights on a single lane) and forces PSRAM streaming. (2) The **fused-loop helper extraction trigger fired and was declined**: the LCD driver iterates row×lane while RMT/NetworkSend iterate linearly — a shared per-light helper fits two of three users and hides the hot loop (30-second rule); the `PinList.h` parser extraction (the other second-user trigger) DID land. **2b is hardware-proven** (bench session 2026-06-11: loopback bit-exact over the full frame, real strip animating on the LOLIN S3), with three more deviations discovered on the bench: (3) the i80 peripheral requires **all 8 data GPIOs** — partial buses are rejected at `esp_lcd_new_i80_bus`, so `pins` takes exactly 8 entries and unused lanes get 0 in `ledsPerPin`; (4) the slot clock is **2.67 MHz (375 ns), not the lineage's 2.4 MHz** — 412 ns "0" pulses exceed newer WS2812B revisions' T0H max and washed the strip white on a direct 3.3 V data line; (5) the loopback self-test transmits the **real frame** (full size, back-to-back) and bit-verifies the whole capture, after the synthetic-burst version passed while the strip failed. Lessons recorded in [docs/history/decisions.md](../history/decisions.md). + +## Shape: two sub-increments + +Increment 1 proved one strand on one pin on classic ESP32. The next useful capability is *more outputs*, and there are two rungs on that ladder: multi-pin RMT (cheap, reuses everything from increment 1, works today on classic + S3) and the parallel LCD_CAM driver on S3 (the real scale step, 8–16 lanes over DMA). Proposed order: **2a then 2b** — 2a delivers multi-output in days and forces the buffer-windowing design that 2b needs anyway; 2b is the new peripheral. + +### 2a — multi-pin RMT (classic ESP32 + S3) + +One `RmtLedDriver` drives N strands, one RMT TX channel per pin, fed from consecutive slices of the source buffer. + +- **Controls.** `pins` becomes a Text control holding a comma-separated GPIO list (`"18,17,16"`), replacing the single `gpio` uint16. A parallel Text control `ledsPerPin` (`"100,100,50"`) says how many lights each pin takes, consumed as consecutive slices of the source buffer; if empty or shorter than `pins`, the unassigned remainder splits evenly over the remaining pins. Two flat lists over a combined `pin:count` syntax: each parses with the same trivial comma parser, and per-output (pin, count) rows are the recognisable WLED LED-settings pattern. +- **Channel limits are per-target facts**, surfaced as an init error in the status slot when exceeded: classic ESP32 has 8 RMT channels × 64 symbols; **S3 has 4 TX channels × 48 symbols** — the current `mem_block_symbols = 64` request must become a per-target constant or S3 init fails. `main.cpp` currently instantiates `RmtLedDriver` under `if constexpr (isEsp32)` (classic only); 2a widens that to S3. +- **Transmission.** Start all channels' `rmt_transmit`, then wait-all — channels run concurrently, so wall time ≈ the *longest* strand, not the sum. This is the main payoff: 4×250 px costs what 1×250 px costs today. +- **Tests.** Host unit test (CI tier): pin/count list parsing, slice arithmetic (counts not summing to buffer count, zero pins, more pins than channels). Hardware tier: the increment-1 loopback self-test runs per pin (jumper the RX pin to each output in turn). + +### 2b — parallel LCD_CAM driver on ESP32-S3 + +8 lanes initial (peripheral does 16), DMA-fed, the hpwit/FastLED-S3 approach studied in [bottom-up §libraries](leddriver-analysis-bottom-up.md) — our own implementation per the history-folder rule. + +- **New driver `LcdLedDriver`** beside `RmtLedDriver` under `DriverBase` — same controls shape as 2a (`pins`, `ledsPerPin`). +- **Fused correct+transpose encode**: per light, `Correction::apply()` then bit-transpose across lanes into the DMA buffer. This is the **second fused driver** the increment-1 locked decision was waiting for — when it lands, extract the shared per-light helper that increment 1 deliberately did *not* extract (two concrete users now exist). +- **Core-1 driver task** lands here (deferred from increment 1, [top-down §7.2](leddriver-analysis-top-down.md)): the driver task owns the DMA buffer lifetime and the in-flight state, which also unlocks the fuller `rmtWs2812Show` error handling deferred from PR #17. +- **Test rig reuse.** Host CI: transpose-encoder unit test (red-first, same Phase A–D methodology as increment 1). Hardware: the RMT **RX** loopback from increment 1 captures *one lane* of LCD_CAM output at a time — S3 has 4 RMT RX channels, so the $0 on-board proof carries over. The sigrok sustained-capture-under-WiFi flicker test belongs to this sub-increment too: the core-1 task is the fix it validates. +- **Phases mirror increment 1**: A contract (host-testable transpose header, no platform include) → B red unit test → C implement to green + platform seam (`platform_esp32_lcd.cpp`) → D hardware loopback proof + `docs/moonmodules/light/drivers/LcdLedDriver.md`. + +## Deferred (product-owner decision, this planning session) + +- **Per-driver buffer window** (`start`/`count` controls on each physical driver, enabling "some lights to ArtNet, others to LED pins"). Additive on `DriverBase` consumers when it lands — no change to the Drivers container or the buffer-passing contract; multi-pin slicing would then subdivide the RMT driver's window instead of the whole buffer. diff --git a/docs/backlog/moonmodules_draft/light/effects/ArtNetReceiveEffect.md b/docs/backlog/moonmodules_draft/light/effects/ArtNetReceiveEffect.md deleted file mode 100644 index 884ea4ed..00000000 --- a/docs/backlog/moonmodules_draft/light/effects/ArtNetReceiveEffect.md +++ /dev/null @@ -1,29 +0,0 @@ -# ArtNet Receive Effect - -Receives ArtNet UDP packets and writes light data into the layer buffer, behaving like any other effect. This means it can be combined with modifiers, participates in layer blending, and is selectable/configurable through the same UI as other effects. - -## Controls - -- `universe_start` (slider, default 0, range 0-255) — first universe to listen on -- `port` (slider, default 6454, range 1-65535) — UDP listen port - -## Rendering - -Opens a UDP socket in setup(). In loop(), polls for pending ArtNet packets (non-blocking, synchronous). Parses OpDmx packets and copies RGB data into the layer buffer. Handles multiple universes for >170 lights. - -## Design notes - -- ArtNet receive as an effect (not a separate input mechanism) is a key architectural choice. It means any external light source is just another MoonModule that writes into a layer buffer. -- Platform UDP receive abstraction needed (complement to the existing UDP send). -- Processing is synchronous at the frame boundary — check for pending packets, don't block. - -## Prior art - -### MoonLight — D_NetworkIn ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Drivers/D_NetworkIn.h)) - -ArtNet/E1.31/DDP receive as a driver node. Supports multiple protocols. - -### projectMM v1 — ArtNetInModule ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/effects/ArtNetInModule.h)) - -v1 ArtNetInModule (commit 54b50bc). Control: `universe_start` (slider 0-255). Treated as an effect within a layer. -ArtNet receive as an effect. Control: universe_start. diff --git a/docs/building.md b/docs/building.md index 5ce4dbec..c9dbde28 100644 --- a/docs/building.md +++ b/docs/building.md @@ -82,7 +82,7 @@ Every host needs [uv](https://docs.astral.sh/uv/), CMake 3.20+, and a C++20 comp The ESP32 target uses ESP-IDF directly, not the Arduino framework. -**Tested IDF version:** **v6.1-dev** (internal `v6.1-dev-399-gd1b91b79b5`). CI builds against this tag and local builds should match (clone command below). Minimum: ESP-IDF v5.1 (C++20 via GCC 12+); the project targets v6.x APIs (`esp_eth_phy_new_generic`, component manager for mDNS) so v5.x may need adjustments. +**Tested IDF version:** **v6.1-dev** (internal `v6.1-dev-399-gd1b91b79b5`). CI builds against this snapshot and local builds should match (clone command below). The why, the alternatives, and how to check for a newer one are in [ESP-IDF version](#esp-idf-version) below. ### Prerequisites @@ -120,9 +120,63 @@ The ESP32 tab in MoonDeck wraps the same steps as cards (Setup → Firmware → ![MoonDeck ESP32 tab](assets/screenshots/moondeck_esp32.png) +### ESP-IDF version + +**Pinned to `v6.1-dev-399-gd1b91b79b5`** (a specific commit on the pre-release v6.1 branch). `setup_esp_idf.py` holds the exact commit in `PINNED_IDF_VERSION` and warns loudly when the installed tree differs, so a stray `git pull` or a fresh shallow clone landing on a newer dev commit is visible rather than silent. Minimum is ESP-IDF v5.1 (C++20 needs GCC 12+); the project uses v6.x APIs (`esp_eth_phy_new_generic`, the component manager for mDNS, the modern RMT/parlio/LCD drivers) so v5.x would need adjustments. + +**Why a dev snapshot and not a stable tag.** As of June 2026 the v6.x line is: **v6.0 is the current stable** (GA 2026-02-27); **v6.1 is still pre-release** (beta1 2026-06-11, RC1 2026-07-23, GA 2026-07-31). We pin a v6.1-dev commit because it carries driver fixes we want on the newer SoCs (P4 parlio, RMT v2 on every chip), and because v6.0 vs v6.1-dev is a small delta. The trade-off is honest: a dev branch gets **no support guarantee** and moves under you, which is exactly why the pin is a fixed commit, not a floating branch. The clean inflection point is **v6.1 GA (2026-07-31)**: re-pin to the `v6.1` tag then, which starts the 30-month support clock (see below). That move is a deliberate re-test pass, not a routine pull. Tracked in [backlog § ESP-IDF version pinning](backlog/backlog.md#esp-idf-version-pinning-pending). + +**v6.0 is the floor — don't depend on anything newer than it.** Because **v6.0 stable is our fallback** if the v6.1 line proves troublesome, the firmware and build tooling must stay buildable on v6.0. The rule is generic: **use no IDF API, component, Kconfig symbol, or tool that isn't present in v6.0.** A feature that exists only on the v6.1-dev branch (or arrives in a later minor) is off-limits until v6.0 is no longer the fallback. When adopting anything new from the IDF, confirm it shipped in v6.0 first (check the v6.0 docs / release notes, not `latest`); if it's v6.1-only, it waits. + +**Explicit exceptions are allowed.** The floor is a default, not an absolute. A feature may step below it (depend on something not in v6.0) when the product owner decides so *explicitly* and the reason is documented at the point it's introduced — in the module spec, a code comment at the dependency, and the commit body. The bar is a conscious, recorded decision, not a silent drift: a floor you can consciously waive with a stated reason stays honest, whereas a rule quietly violated does not. Each such exception also narrows the v6.0 fallback (that target now needs the newer dependency too), so it states what the fallback loses. The known exception today is **P4 WiFi over the C6 co-processor**, which needs `esp_wifi_remote` / esp-hosted (a managed component outside mainline v6.0); it is an accepted, documented exception, scoped to the P4 target, tracked in the [backlog](backlog/backlog.md#esp32-p4-support--rounds-3-4-in-progress). + +**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.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. + +**How to check for a newer version.** + +- **Latest stable + all tags:** the [releases page](https://github.com/espressif/esp-idf/releases), or from a clone: `git -C ~/esp/esp-idf fetch --tags && git -C ~/esp/esp-idf tag -l 'v6.*'`. +- **What our tree currently is:** `cat ~/esp/esp-idf/version.txt`, or `git -C ~/esp/esp-idf describe --tags`. `setup_esp_idf.py` prints this and flags drift from the pin. +- **The release schedule + EOL dates:** the upstream [`ROADMAP.md`](https://github.com/espressif/esp-idf/blob/master/ROADMAP.md) (beta/RC/GA dates per minor, and when each older minor reaches end-of-life). + +Moving to a different release is never automatic: bump `PINNED_IDF_VERSION` in `setup_esp_idf.py`, re-clone or check out the new tag, then run the full ESP32 build + hardware re-test pass before committing the bump. + +#### Adopting the v6.x ecosystem changes + +v6.0 introduced ecosystem-level changes beyond the API surface. The stance, under [§ Principles → Industry standards](../CLAUDE.md#principles), is to **embrace these as the ESP32 standard** — if the IDF makes something the recognised way to build, install, provision, or ship, that's the path we want, not a bespoke one we maintain alone. We adopt them **step by step** (each its own commit + hardware re-test) rather than all at once, and only after they clear the **v6.0-floor rule** above, but the default is *yes, adopt*, with the burden on *why not* — not the reverse. + +Two guardrails bound the "embrace everything" stance: + +- **Platform-generic stays intact.** These are ESP32-specific gains; none may regress Teensy or the desktop (macOS / Windows / Linux) paths, which don't use ESP-IDF at all. An IDF feature is adopted *inside* the ESP32 platform layer / build tooling, never by leaking an IDF assumption into shared `src/` or the desktop build. If embracing a v6.x feature would touch a cross-platform seam, that seam stays abstracted (the existing platform-boundary rule). +- **The v6.0 floor.** Adopt only what's in v6.0 (see the rule above), so the v6.0 fallback keeps working. + +Each row below states where we are and the trigger to move. + +| Change | Where we are now | How / when to adopt | +|---|---|---| +| **EIM** (ESP-IDF Installation Manager) — the new default, cross-platform installer; Espressif says `install.sh` / `idf_tools.py` are "no longer needed" | `setup_esp_idf.py` drives the legacy `install.sh` / `install.bat`. Works, but is now the *old* documented path. | **Adopt any time** — EIM shipped *in v6.0*, so it clears the v6.0-floor rule, and it has a headless CI mode. This is the highest-priority alignment and doesn't need to wait for the re-pin. Add EIM as the **preferred** path in `setup_esp_idf.py` (CLI: `eim install`), keep `install.sh` as a documented fallback for one release. Keep the exact-commit pin: EIM's multi-version management *helps* reproducibility, it doesn't replace the pin. | +| **PSA Crypto** — legacy mbedTLS crypto APIs deprecated in favour of the PSA API | No direct exposure: we never call mbedTLS ourselves; OTA uses `esp_https_ota` + `esp_crt_bundle_attach` ([platform_esp32_ota.cpp](../src/platform/esp32/platform_esp32_ota.cpp)), which wrap crypto internally. | Nothing to migrate while we stay on high-level components. **Watch** only: if a future feature needs hashing/signing directly (e.g. signed-OTA verification, a device identity), write it against the **PSA API** from the start, not legacy mbedTLS. Trigger: first direct crypto use. | +| **`network_provisioning`** — Espressif's Unified Provisioning subsystem, renamed from `wifi_provisioning` in v6.0. Transports: **BLE (GATT)** + **Wi-Fi SoftAP**. Clients: official iOS/Android apps for both, plus `esp_prov` (a Python CLI on Linux/macOS/Windows). Transport-agnostic but ships no web/serial client. | We provision over [Improv](../src/core/ImprovProvisioningModule.h) — serial (USB) + BLE, driven from the **browser** (ESP Web Tools) or a serial CLI. That covers the *web-installer / no-app* onboarding well. What we **don't** have is the IDF-native **phone-app + SoftAP** flow (open the ESP app, pick the device's AP, hand it credentials) that most shipping ESP32 products offer. So this is a real coverage gap, not a duplicate: the two standards meet only on BLE and own different front-ends. | **Adopt to close the gap — this is a planned capability, not a maybe.** `network_provisioning` is in v6.0 (clears the floor) and is *the* IDF-native standard, so embracing it is exactly the stance above. Add it as a **sibling provisioning module** beside ImprovProvisioning (both live as Peripheral/System modules; the device can offer whichever transports its chip supports), reusing the same WiFi-credential plumbing. Not a replacement for Improv — they cover different front-ends (browser vs phone-app), and a product can want either. The phone-app + SoftAP path is the part that makes ESP32 deployment feel product-grade. Trigger: scheduled as one of the v6.0-adoption iterations (see below). | +| **CMake Build System v2** — the named successor to the current build system; technical preview in v6.0/6.1, has its own migration guide | Standard `idf.py` build (v1). Our component is a thin `idf_component_register()` wrapper, so the migration surface is small. | **Watch until it's GA** (not while it's preview — adopting a preview build system would be the opposite of "common patterns first"). Trigger: v2 ships as the default. Then dry-run a build under v2, fix any `idf_component_register()` / Kconfig-dependency fallout, switch. Low risk given how little custom CMake we have. | +| **Built-in MCP server** (`idf.py mcp-server`) — lets an AI assistant drive build/flash/monitor/debug directly | Not used. Agents and humans both go through the `scripts//*.py` layer (the uniform interface in [scripts/MoonDeck.md](../scripts/MoonDeck.md)), which wraps pin-drift checks, per-firmware build dirs, and KPI collection. | **Evaluate, don't default to it.** The risk is a *second control path* that bypasses our script policy, and it's ESP32-only (no desktop), so it can't be the uniform path. If adopted, wrap it *behind* a script (`scripts/run/idf_mcp.py`) so the policy layer still applies, rather than pointing the agent at raw `idf.py`. Trigger: a concrete debug workflow the scripts can't cover. | + +The general rule: **anything already in v6.0 we adopt proactively** (it clears the floor, so there's no reason to wait — EIM and `network_provisioning` are both here), while **preview / not-yet-in-v6.0 features wait** until they're stable *and* in our floor. Each adoption is its own commit with its own hardware re-test, and none may regress the Teensy / desktop paths. + +**Adoption iterations (the step-by-step plan).** We close the v6.0 gaps one at a time, picked up as normal feature commits: + +1. **EIM installer** — rework `setup_esp_idf.py` to prefer `eim install`, keep `install.sh` as a one-release fallback. Smallest and lowest-risk (build-path only, no firmware change, no hardware re-test), and EIM's multi-version management is what cleanly supports the v6.0-floor / v6.1-fallback juggling — so it sequences first as an enabler for the rest. +2. **`network_provisioning`** — the headline capability: a sibling provisioning module beside ImprovProvisioning adding the phone-app + SoftAP onboarding flow. Its own plan (spec before code), a `Peripheral`/System module reusing the WiFi-credential plumbing, BLE-stack cost weighed per chip. +3. Further v6.0 items (PSA-native crypto, CMake v2, MCP) are pulled in as their triggers fire (first direct crypto use; v2 GA; a debug need), per the rows above. + +Tracked in [backlog § ESP-IDF version pinning](backlog/backlog.md#esp-idf-version-pinning-pending). + ### Firmware variants -`build_esp32.py --firmware` selects one of four shipping variants. The key combines chip name + feature flags + (for SKU-sensitive chips) module. ("Firmware" here is the compiled binary; the physical board is a separate concept — see [architecture.md § Firmware vs board](architecture.md#firmware-vs-board).) +`build_esp32.py --firmware` selects one of the shipping variants. The key combines chip name + feature flags + (for SKU-sensitive chips) module. ("Firmware" here is the compiled binary; the physical board is a separate concept — see [architecture.md § Firmware vs board](architecture.md#firmware-vs-board).) `build_esp32.py --help` lists the full set; the main ones: | `--firmware` | IDF target | `SDKCONFIG_DEFAULTS` | What's in the image | |---|---|---|---| @@ -130,10 +184,12 @@ The ESP32 tab in MoonDeck wraps the same steps as cards (Setup → Firmware → | `esp32-eth` | `esp32` | `sdkconfig.defaults;sdkconfig.defaults.eth` | Ethernet only. WiFi components dropped via `-DEXCLUDE_COMPONENTS=esp_wifi;wpa_supplicant;esp_coex` and `-DMM_ETH_ONLY=1`. Smaller image, more free RAM. Olimex ESP32-Gateway pins baked in (LAN8720 @ MDIO 0, PHY RST GPIO 5). | | `esp32-eth-wifi` | `esp32` | `sdkconfig.defaults;sdkconfig.defaults.eth` | Ethernet + WiFi. Same Olimex pin map. Full Ethernet → WiFi STA → WiFi AP cascade. | | `esp32s3-n16r8` | `esp32s3` | `sdkconfig.defaults;sdkconfig.defaults.esp32s3-n16r8` | ESP32-S3 DevKitC-1 with the N16R8 module (16 MB flash, 8 MB octal PSRAM). WiFi only. | +| `esp32p4-eth` | `esp32p4` | `sdkconfig.defaults;sdkconfig.defaults.esp32p4-eth` | [Waveshare ESP32-P4-NANO](https://www.waveshare.com/wiki/ESP32-P4-Nano), Ethernet only (IP101 PHY — pins in the `ethPins` config, not sdkconfig). The WiFi-less fallback. Pulls the `espressif/ip101` managed PHY component. | +| `esp32p4-eth-wifi` | `esp32p4` | `…;sdkconfig.defaults.esp32p4-eth;sdkconfig.defaults.esp32p4-eth-wifi` | Same NANO board, Ethernet + WiFi. The P4 has no native radio; WiFi runs on the on-board **[ESP32-C6](https://www.espressif.com/en/products/socs/esp32-c6)** over SDIO via the [`esp_wifi_remote`](https://components.espressif.com/components/espressif/esp_wifi_remote) + [`esp_hosted`](https://github.com/espressif/esp-hosted-mcu) managed components (pulled P4-only). These are a deliberate [v6.0-floor exception](#esp-idf-version). The `esp_wifi_*` API is unchanged; the platform layer only adds an `esp_hosted` bring-up before `esp_wifi_init`. First build is longer (managed-component fetch). | ESP-IDF v6.x has no `CONFIG_ESP_WIFI_ENABLED` switch (the symbol is forced on for WiFi-capable SoCs), so dropping WiFi at compile time happens via `EXCLUDE_COMPONENTS` plus `MM_NO_WIFI` (set when `MM_ETH_ONLY=1`, applied in `esp32/main/CMakeLists.txt`). The `esp32-eth` variant takes this path; `esp32-eth-wifi` keeps everything compiled in and uses the runtime cascade in `NetworkModule`. -Each firmware has its own build dir at `build/esp32-/`, so all four variants can coexist on disk. `build_esp32.py` points `idf.py -B` at the per-firmware dir; switching firmwares is just a different `--firmware` argument, no clean rebuild penalty. Same-firmware rebuilds stay incremental, as before. Disk usage scales with the number of firmwares built (≈100 MB each), and a future rename would orphan the old dir — clean with `scripts/build/clean_esp32.py --firmware ` or `--all`. +Each firmware has its own build dir at `build/esp32-/`, so all variants can coexist on disk. `build_esp32.py` points `idf.py -B` at the per-firmware dir; switching firmwares is just a different `--firmware` argument, no clean rebuild penalty. Same-firmware rebuilds stay incremental, as before. Disk usage scales with the number of firmwares built (≈100 MB each), and a future rename would orphan the old dir — clean with `scripts/build/clean_esp32.py --firmware ` or `--all`. Each ESP32-S3 SKU has its own firmware key because the sdkconfig fragment encodes flash size, partition table, and PSRAM mode — flashing an `n16r8` binary onto a different module (e.g. N8R2) either misaligns the partition table (boot loop) or fails PSRAM init. New SKUs become new keys (e.g. `esp32s3-n8r8`); there is no generic `esp32s3` shortcut. diff --git a/docs/coding-standards.md b/docs/coding-standards.md index b25ed17a..f6f93adf 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -13,6 +13,7 @@ Decided once; not re-derived per file. - **No `using namespace` in headers.** In a `.cpp`, `using namespace mm;` is allowed at file scope. In a header it pollutes every translation unit that includes it. - **Semantic variable names.** Name variables for what they represent, not just their type. `availableHeap` not `available`, `internalHeap` not `internal`, `lutBytes` not `bytes`. A reader should understand the variable without looking at its assignment. - **No hard line wraps in markdown.** Let the editor soft-wrap. Hard wraps make diffs noisier than they need to be. +- **No em-dashes (` — `) in prose.** Use a comma, semicolon, colon, parentheses, or a full stop instead, whichever the clause actually calls for. Applies to docs, comments, and commit messages. (Code is exempt: a literal `—` in a UI string or test fixture stays.) Existing em-dashes get replaced as files are touched, not in a single sweep. ## Prefer integers, store values in their native shape diff --git a/docs/history/README.md b/docs/history/README.md index 160c9943..f9cd84a3 100644 --- a/docs/history/README.md +++ b/docs/history/README.md @@ -10,7 +10,7 @@ Three kinds of document: ### Friend-repo activity digests -Monthly logs of what shipped on related open-source LED projects — the live landscape projectMM watches to sharpen its own designs (we study their approaches, we don't copy their code). Generated by the [digest prompt](#digest-prompt-reusable) below. +Monthly logs of what shipped on related open-source LED projects — the live landscape projectMM watches to sharpen its own designs under the *Industry standards, our own code* principle ([CLAUDE.md § Principles](../../CLAUDE.md#principles)): study to think, write fresh, never copy. Generated by the [digest prompt](#digest-prompt-reusable) below. - [FastLED-FastLED.md](FastLED-FastLED.md) — the LED-animation library; ESP32/Arduino driver + colour math. - [wled-WLED.md](wled-WLED.md) — upstream WLED firmware. diff --git a/docs/history/decisions.md b/docs/history/decisions.md index 8cff9847..20166e7b 100644 --- a/docs/history/decisions.md +++ b/docs/history/decisions.md @@ -695,3 +695,114 @@ Moving the repo `ewowi/projectMM → MoonModules/projectMM` and cutting the firs 2. **Don't gate asset-publishing behind a Pages-only `environment:` — a tag fails the gate before any step runs, silently dropping all release assets.** The `release` job did two things (upload release binaries *and* deploy GitHub Pages) under one `environment: github-pages`. That environment's protection rule allowed only `main`. When publishing v1.0.0 created the `v1.0.0` *tag*, it re-triggered the workflow on `refs/tags/v1.0.0`; GitHub evaluates the environment protection rule **at job start, before the first step** — the tag failed it, so the *entire job was rejected in 2 seconds*, including the "Publish GitHub release" step. Result: a published release with **zero binaries**, and a red X whose message ("Tag v1.0.0 is not allowed to deploy to github-pages") pointed at Pages, not at the asset upload that actually got skipped. The two symptoms (missing assets + red X) had one root cause: coupling. **Fix:** split into a `release` job (no environment, `contents: write`, uploads assets — runs on tags) and a `deploy-pages` job (`needs: [release]`, `if: ref==main`, carries the `github-pages` environment). Tags publish assets; `main` deploys Pages. **Generalisable:** an `environment:` on a job gates the *whole job* via a ref-based protection rule evaluated up front — never put work that must run on refs the environment forbids (tag-triggered asset upload) in the same job as the environment-gated work (production deploy). Recovery without re-tagging: `gh workflow run release.yml -f tag=vX.Y.Z` replays the fixed job and uploads assets onto the existing release. 3. **A failure that looks like the change is often the environment — verify before assuming a regression.** Recurring across this branch: a scenario "failed" its 120µs tick contract at 536µs (machine load from concurrent builds + MoonDeck + a preview server — re-ran isolated at 118µs, passed); Improv reported `UNABLE_TO_CONNECT` while the device was *already provisioned and reachable* (async-confirmation timeout, not a join failure); MoonDeck showed `0/0 online` while the device served HTTP 200 (the active network record had an empty subnet and a duplicate record held the device). None were code or transfer defects. **The rule:** when something fails right after a change, first prove the failure is *about* the change — re-run isolated, probe the actual end state (ping/curl the device, read the real CI error line, check the env), and only then edit code. Several hours here would have been saved by checking the device was reachable *before* debugging the "WiFi failure". + +## Lessons from the LCD_CAM WS2812 driver bench debug (LcdLedDriver, S3) + +Bringing the 8-lane LCD_CAM driver from "compiles and ticks" to "strip actually animates" took three stacked root causes, each masked by the one before it. The through-line: **every layer of indirection between "the code ran" and "the LED lit" hid a failure the layer above couldn't see.** Record the chain so the next parallel-output driver (16-lane LCD, P4 PARLIO, Teensy FlexIO) skips the dig. + +1. **The i80 peripheral requires ALL `bus_width` data GPIOs — a partial bus never exists.** `esp_lcd_new_i80_bus` validates every `data_gpio_nums[0..bus_width)` and rejects `GPIO_NUM_NC` entries (`esp_lcd_panel_io_i80.c`, "configure GPIO failed"). A 1-pin config therefore never initialized: no bus, no transmit, dark strip — while the UI showed a configured, enabled driver. **Fixes:** the driver demands exactly 8 pins and reports `LCD bus needs exactly 8 pins` in the status slot (unused lanes take `0` in `ledsPerPin` and idle LOW); the loopback builds its private bus full-width from the driver's real pin set. **Generalisable:** when a peripheral claims a *group* of pins, surface the group contract in the control's validation — don't let a config that the hardware layer will reject look valid in the UI. + +2. **"Capacity still fits" is not "config unchanged" — a resize-optimisation early-return swallowed pin changes.** `reinit()` skipped the bus rebuild when the existing DMA buffer was big enough — correct for grid resizes, wrong for pin edits: moving lane 0 from GPIO 13 to 18 keeps the frame size identical, so the bus kept clocking out on the OLD pins and the strip pin carried nothing. **Fix:** record the pin set (data + WR + DC) the live bus was built with and compare on every reinit; any difference forces the rebuild. **Generalisable:** an "is the existing resource still good?" fast path must compare *identity* (what the resource is bound to), not just *capacity* (how big it is). Same family as the LOLIN branch's stale-cache lesson: the cached check was true about the wrong invariant. + +3. **Gate the LCD driver on `CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED`, NOT `CONFIG_SOC_LCD_I80_SUPPORTED` — the names look interchangeable and are not.** `lcdLanes` (and the whole driver wiring) first gated on `SOC_LCD_I80_SUPPORTED`, on the assumption it meant "has the S3-style LCD_CAM i80 bus." It doesn't: **the classic ESP32 also defines `SOC_LCD_I80_SUPPORTED=1`** for its unrelated *I2S-LCD* peripheral. So on the classic chip `lcdLanes` became 8, the `LcdLedDriver` got wired at boot, and `esp_lcd_new_i80_bus()` hung the watchdog trying to init an LCD_CAM bus the chip lacks → **boot-loop on every classic ESP32** shipped with that gate. The correct macro is **`SOC_LCDCAM_I80_LCD_SUPPORTED`**, defined only on chips with the actual LCD_CAM peripheral (S3 / P4) — which is what `esp_lcd`'s i80 driver requires. Bisect-proven (the prior commit booted, the LCD-driver commit looped) and hardware-verified after the fix (classic boots with LcdLed absent, S3/P4 keep it). The gate lives in `src/platform/esp32/platform_config.h` (`lcdLanes`) and the `#if` in `platform_esp32_lcd.cpp`. **Generalisable:** SOC capability macros with near-identical names can describe *different peripherals* on different chips — verify the macro is actually defined where you expect (and only there) before gating on it; a "supported" flag that's true on a chip you didn't mean is worse than a missing one, because it silently activates code on the wrong hardware. + +3. **A self-test that passes while the device fails is telling you what the test doesn't cover — close the gap before theorising.** The first loopback transmitted a 3-byte synthetic pattern through a 136-byte transfer and PASSED while the real strip washed out max-white. The differences between test and reality were the suspect list: frame size (5.4 KB, multi-descriptor GDMA chain), sustained back-to-back cadence, and pulse timing as seen by a real WS2812 rather than an RMT RX capture. Upgrading the test to transmit the driver's REAL frame (same size, same chain, repeated like the render loop) and bit-verify the WHOLE capture (RMT RX with the DMA backend, ~1536 symbols) eliminated the first two suspects in one run — leaving timing as the only remaining difference, which was the answer. **Generalisable:** when test and reality disagree, enumerate what the test abstracts away and make the test transmit the genuine article; each closed gap either finds the bug or eliminates a theory with proof instead of speculation. + +4. **Modern WS2812B reads a 412 ns "0" as "1" — T0H max is ~380 ns on newer revisions, and 3.3 V direct drive eats the remaining margin.** The classic 3-slots-at-2.4 MHz encoding (hpwit / FastLED lineage, slot ≈ 416 ns) produced a waveform the RMT RX decoded perfectly — and the strip rendered as max white with flicker: most "0" pulses sampled as "1" (mostly-ones ≈ white; the animation's actual 1-bits flicker through). The same strip on the same pin ran clean from the RMT driver's 350 ns zeros, which isolated timing as the only variable. **Fix:** pclk 2.67 MHz → 375 ns slots ("0" = 375 ns, "1" = 750 ns, bit 1125 ns), inside every WS2812B revision's window; latch pad resized to keep ≥300 µs. The lineage gets away with 416 ns because those rigs typically sit behind a 74HCT level shifter that restores threshold margin. **Generalisable:** datasheet timing windows shrank across WS2812B revisions — design new encoders against the NEWEST revision's T0H max (≤380 ns), and treat "RMT-captured waveform is correct but the strip disagrees" as a threshold/margin problem, not a logic problem. + +Bench-procedure notes worth keeping: a board that drops STA mid-session falls back to softAP silently — poll the device's reachability before interpreting an unanswered API call as a wedge (one STA beacon-timeout drop was observed seconds after the LCD bus first went live; not reproduced since, watch for recurrence). And the differential test that cracked the case twice: drive the same pin/strip with the already-proven RMT driver — it exonerates wiring, power, and the strip in one move. + +## Diagnosing LED flicker: eliminate firmware with hardware tests before blaming (or fixing) the wire + +A classic ESP32 driving a WS2812 strip on RMT showed random wrong colours on LEDs the effect left black ("blue flicker", later "random flicker"). The temptation is to guess — WiFi interference, a buffering bug, timing — and start changing code. The bench session that resolved it instead ran a four-step elimination, each step a *measurement*, and the answer fell out: + +1. **Capture the source/preview buffer** — it held zero stray colour. The effect output is clean, so the corruption is downstream of the logical buffer (not an effect or correction bug). +2. **Run the whole-frame loopback self-test** through a short jumper — bit-exact `PASS`, repeatedly, even under WiFi load. The RMT encode + transmit emit a correct WS2812 waveform on real silicon, so the firmware/peripheral is innocent. +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). + +**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. + +**Red-herring note:** the flicker's *appearance* shifted (blue-only → random) after an unrelated change (the `lightPreset` default went RGB→GRB, remapping which channel carries which colour) plus a pin swap. The underlying electrical fault was identical; only the colour mapping over it changed. A changed symptom is not proof a code change caused it — confirm the mechanism, not the surface. + +## ESP32-P4 support, round 1 — per-board Ethernet pin config, and the P4's WiFi reality + +Adding the Waveshare ESP32-P4-NANO (round 1 of 4: board + Ethernet-only; later rounds add the Parlio LED driver, C6-co-processor WiFi, and the Parlio loopback). Two findings worth keeping: + +**The P4 has no native WiFi.** `SOC_WIFI_SUPPORTED` is absent on esp32p4 (it has EMAC, RMT, LCD_CAM i80, and Parlio, but no radio). WiFi on these boards comes from an on-board **ESP32-C6 co-processor over SDIO** via the `esp_wifi_remote` / esp-hosted stack — which is a managed component, **not in mainline IDF v6.1-dev**. So round 1 ships Ethernet-only (`MM_ETH_ONLY`, WiFi components excluded), and round 3 will introduce a WiFi abstraction seam so the P4 routes to the remote stack while classic/S3 stay on native `esp_wifi`. The C6 SDIO pins on the P4-NANO (recorded for round 3, and so round-2 Parlio avoids them): CLK 18, CMD 19, D0-D3 14-17, C6 reset 54. + +**Ethernet pins became a per-target compile-time config, not scattered #ifdefs.** `ethInit()` had the Olimex RMII/PHY pins baked in as literals. The P4-NANO needs different ones (IP101 PHY addr 1, MDC 31, MDIO 52, reset 51, and crucially an *external* 50 MHz RMII clock fed IN on GPIO50 — `EMAC_CLK_EXT_IN`, the opposite of Olimex's `EMAC_CLK_OUT`). Rather than `#ifdef` the pins inside `ethInit`, an `EthPinConfig` struct + a `constexpr ethPins = isEsp32P4 ? {…} : {…}` lives in `platform_config.h`, and `ethInit` reads it. This keeps the platform-boundary rule (compile-time branching is `if constexpr` on config flags, not `#ifdef` in domain code), turns the Olimex magic numbers into a named config, and is the seam future eth boards extend. Full *runtime* PHY/pin selection stays a 2.0 backlog item — this is compile-time-per-target, which is all the board variants need. + +**The IP101 PHY driver is a managed component in IDF v6.** IDF v6 moved every per-PHY driver out of `esp_eth` core into the component registry (`espressif/ip101`). The generic PHY (Olimex LAN8720) stays in core, so only the P4 build pulls `espressif/ip101` (added to `idf_component.yml`); the IP101 ctor is behind `if constexpr (ethPins.isIp101)` so non-P4 builds never reference the symbol. A subtle build-script trap fixed alongside: the eth-fragment detector matched only `.eth`, so the new `sdkconfig.defaults.esp32p4-eth` (ending `-eth`) would have silently set `MM_NO_ETH` and stubbed Ethernet out — the matcher now accepts both `.eth` and `-eth`. + +## ESP32-P4 round 2 — Parlio LED driver: a simpler peripheral, the same encoder + +Adding `ParlioLedDriver` (the P4's parallel WS2812 path, round 2 of 4) turned out to be mostly *subtraction* from the LCD driver, not addition — worth recording why, so the next parallel-output backend (16-lane, Teensy FlexIO) starts from the right base. + +**Parlio is a simpler peripheral than the LCD_CAM i80 bus, so the driver is simpler too:** +- **No sacrificial WR/DC pins.** The i80 bus mandates two real GPIOs (pixel-clock + data/command) that WS2812 ignores — `LcdLedDriver` carries `clockPin`/`dcPin` controls just to feed them. Parlio generates the clock internally (`output_clk_freq_hz`), so `ParlioLedDriver` has neither control. +- **No exactly-8-pins rule.** The i80 layer rejects a partial bus (`esp_lcd_new_i80_bus` requires a real GPIO on every data line), which is why `LcdLedDriver` demands exactly 8 pins. Parlio takes `data_gpio_nums[]` with unused = `-1`, so the driver runs on any 1–8 lanes. The "exactly 8" validation simply isn't there. + +**The encoder is shared, not duplicated.** `LcdSlots.h::encodeWs2812LcdSlots` outputs "one bus byte per slot, bit L = data line L" — and a Parlio bus byte is byte-identical to an i80 bus byte. So `ParlioLedDriver` reuses the same encoder and the same `unit_LcdLedEncoder.cpp` test; no new encode code, no new encode test. (This is the "reuse a recognisable shape" rule paying off: a second parallel peripheral cost ~0 encoder lines.) + +**One Parlio API constraint to remember:** `data_width` must be a *power of two* (`(w & (w-1)) == 0`), ≤ `SOC_PARLIO_TX_UNIT_MAX_DATA_WIDTH`. We always create the unit at `data_width = 8` (matching the encoder's 8-bit bus byte) and set unused lanes' GPIOs to `-1` — rather than narrowing the bus to the lane count, which would mismatch the encoder's byte layout. So 1–8 driver lanes all map onto an 8-wide Parlio bus; the unused high bits are simply not wired. + +**Clock math is identical to the LCD driver:** Parlio's default `PLL_F160M` (160 MHz) ÷ 60 = 2.67 MHz = the same WS2812 slot rate (375 ns) the LCD driver settled on for the T0H-margin reason — so the timing decision carries straight across with no rework. + +**Default pins must dodge the P4's strapping GPIOs.** The first cut of `ParlioLedDriver` defaulted `pins` to `36,37,38,...` — but **GPIO 34-38 are the ESP32-P4's strapping pins** (boot-mode control). It inited fine on the bench (nothing drove them during the boot window), but defaulting LED *output* onto strapping pins is a latent footgun: a driven level at the wrong moment can change boot mode. Fixed the default to 8 strapping-safe pins (`pins="20,21,22,23,24,25,26,27"`) with `ledsPerPin="64"` putting all 64 lights on lane 0 — a serpentine 8×8 panel is one 64-LED strand, and the other 7 lanes idle LOW at zero cost (the parallel DMA transfer time is set by the *longest* lane, not the lane count, so 1-of-8 is the same speed as 8-of-8; reassigning `ledsPerPin` adds strips later with no pin change). The clear GPIOs on the P4-NANO, after Ethernet (28-31, 49-52), C6 SDIO (14-19, 54), I2C (7-8) and **strapping (34-38)**, are 20-27, 32-33, 39-48. **Generalisable:** when picking default output pins for a new chip, pull the strapping-pin list from the datasheet first — "it booted on the bench" doesn't prove a strapping pin is safe to drive, only that nothing drove it at the wrong instant. + +## Audio input (INMP441 mic) — ship the manual core, design fresh from the datasheet + +The first audio-reactive capability: `AudioModule` (a SystemModule Peripheral) reads an INMP441 I²S mic and publishes an `AudioFrame` (level + 16-band spectrum + dominant peak) that `AudioVolumeEffect` and `AudioSpectrumEffect` consume. Decisions worth keeping: + +**Two seams, everything else host-tested.** Only the I²S read and the FFT *kernel* sit behind the platform boundary (`platform_esp32_i2s.cpp`: IDF `i2s_std` + esp-dsp's float `dsps_fft2r_fc32`). All the signal math — DC strip, RMS, the Hann window, the magnitude→16-band log mapping — is pure header-only domain code (`AudioLevel.h`, `AudioBands.h`), the `RmtSymbol.h`/`LcdSlots.h` shape. The desktop `audioFft` stub is a real (naive O(n²)) DFT, so the *whole* pipeline (window → FFT → bands) runs end-to-end in CI on synthesized sines with no hardware. esp-dsp **float** (not fixed-point) is the right call on an FPU chip — Espressif's own benchmark fact. + +**Effects reach the producer via a static accessor, not a boot setter.** The normal producer/consumer wiring passes a `const Foo*` to the consumer once in `main.cpp` (PreviewDriver→HttpServer). That works when both ends are boot-wired singletons — but an audio effect can be *added through the UI after boot*, and a boot setter only ever wired the boot instance. So `AudioModule::latestFrame()` is a static accessor: the active mic registers itself in `setup()`, clears the pointer in `teardown()`, and returns a static silent frame when there's no mic. Any add/remove order yields the live frame or valid silence, never null. Reach for this whenever a *user-addable* consumer needs a *singleton* producer's data. + +**Found on hardware, pinned by tests.** Two bugs the desktop build couldn't surface, both now regression-tested: (1) a missing `registerType` made `create("AudioModule")->markWiredByCode()` deref null and **boot-loop** — the fix is the registration plus a null-guard, pinned by a "registered + createable" test; (2) the I²S read **blocked the render tick** ~7.7 ms at a 20 ms timeout — fixed to a non-blocking read plus a cross-tick sample accumulator (a full 512-sample block takes ~23 ms at 22 kHz, longer than one tick), dropping AudioModule to ~400 µs. The INMP441 is also on the **left** I²S slot here (the right reads empty) — one config line, the first bench suspect when level floors with sound present. + +**Shipped the manual core; the adaptive conditioner was prototyped and removed.** The mic, FFT, log/dB scale, two effects, and a `floor`/`gain` manual control surface are the solid, host-tested increment that landed. A self-calibrating conditioner (auto noise-floor + AGC + smoothing, goal "sound off → dark, sound on → vivid") was built and then *deleted* — it needs bench tuning in a **quiet** room, and the development environment (a campground van with strong, *varying* low-frequency engine/inverter rumble) was the adversarial worst case that kept it from settling: a per-band auto-floor removes *constant* tones but can't track *varying* broadband ambient; global AGC pumps the residual to full in silence; a relative per-band floor fixes treble over-cut but flattens the spectrum. Lesson — **land the manual core, treat adaptive auto-tuning as its own increment, and tune it where the noise floor is real-quiet, not in the field.** Also: `level` is overall RMS loudness (independent of the FFT) — don't derive it from the bands, or it stops fluctuating with volume. + +**Designed fresh from the datasheet + textbook DSP, not from a prior project.** Per the product owner: don't trace WLED-MM (or any existing controller) for naming, structure, or functionality — build something independent. The concrete DSP choices and *why* (Hann/RMS/geometric-bands/argmax, and why a flat ±3 dB mic needs no per-frequency correction table) are documented in the module spec, [AudioModule.md](../moonmodules/core/AudioModule.md), where an integrator looks for them. The lesson worth keeping here is consistent with the repo's *Industry standards, our own code* principle (study with respect, don't copy): **reference proven behaviour, don't trace structure.** Here the datasheet made even the behaviour-reference unnecessary — a flat ±3 dB mic has no per-frequency error to correct, so the hand-tuned band-correction table years of prior-project work produced was the wrong tool, and the textbook defaults were enough. Read prior art to understand *what* works and *why*; let the hardware datasheet and standard DSP decide *how*, so the result is independent by construction rather than a renamed trace. + +## ESP32-P4 round 3 — WiFi via the C6: the abstraction the earlier round feared wasn't needed + +Round 1 recorded that round 3 "will introduce a WiFi abstraction seam so the P4 routes to the remote stack while classic/S3 stay on native `esp_wifi`." The actual implementation was far smaller, and *why* is the lesson. + +**`esp_wifi_remote` is API-compatible, so there was no seam to build.** From the P4's side you still call `esp_wifi_init()`, `esp_wifi_connect()`, `esp_wifi_scan_start()`, `esp_netif_create_default_wifi_sta()` — identical signatures; the component forwards them to the C6 over SDIO. So the entire existing WiFi platform layer (`wifiStaInit`/`wifiApInit`/scan/tx-power, ~230 lines) compiles and runs **unchanged** on the P4. The only genuinely new code is a **two-call prelude** — `esp_hosted_init()` + `esp_hosted_connect_to_slave()` — that must run *before* `esp_wifi_init()`, added to `ensureWifiInit()` behind `if constexpr (platform::usesRemoteWifi)` (= `isEsp32P4 && hasWiFi`). Lesson: before designing an abstraction for "two backends," check whether the vendor already made them API-identical — here the "seam" was a 15-line prelude, not a routing layer. *Concrete first* would have caught this even without the foresight; the round-1 note over-scoped from a position of less information. + +**Init ordering is the whole risk.** Espressif's docs and the community starters are emphatic: esp_hosted must be fully up before anything touches the WiFi stack, and wrong ordering surfaces as **NVS errors / asserts / a silent hang**, not a clean error — so it reads like an unrelated bug. The prelude-before-`esp_wifi_init` placement is the mitigation; it's the first thing to check if a P4-wifi build misbehaves at boot. + +**Pulled P4-only via a `rules` gate.** `esp_wifi_remote` + `esp_hosted` are added to `idf_component.yml` with `rules: - if: "target == esp32p4"`, so classic/S3 and the eth-only P4 build never fetch or compile them. This is the clean answer to "managed components are per-project" — the gate makes the pull per-target. (The pre-existing `ip101` entry could use the same gate but doesn't; not worth churning.) + +**A deliberate v6.0-floor exception.** These components live outside mainline v6.0, so the build steps below the [v6.0 floor](../building.md#esp-idf-version). That floor has an explicit-exception clause exactly for cases like this: the product owner accepted it consciously, it's documented at every introduction site (the yml, the sdkconfig fragment, the platform flag, building.md), and it's scoped to the P4 — the other targets keep the v6.0 fallback intact. Lesson: a floor rule with a *documented-exception* path beats a rigid one; the exception stays honest because it's recorded, not silent. + +## RMT timeout: don't cancel a stuck transfer — the cancel crashes classic ESP32 + +A deferred "fuller error handling" item for `rmtWs2812Show`/`rmtWs2812Wait` (🐇 CodeRabbit PR#17) turned out to be mostly *already done* and partly *actively harmful* — a good case of *default to subtraction*. + +**Most of it had already landed** in the multi-pin (2a) work: `rmtWs2812Transmit` already returns the `rmt_transmit` result, and `RmtLedDriver::loop()` already tracks `started[]` so it only waits on channels whose transmit succeeded (a failed one gives no done-callback, so waiting would burn the full 1 s timeout). And there's no mid-transmit corruption risk because the symbol buffer is re-encoded from scratch *before* any transmit each tick. So two of the three "to-do" items were no-ops. + +**The remaining item — cancel the in-flight transfer on timeout via `rmt_disable()` — is a trap.** `rmt_disable()` while a transmission is still active triggers an **interrupt-WDT panic on classic ESP32** (espressif/esp-idf#17692; classic-only, S3/C6/P4 unaffected). That trades a self-healing dropped frame for a crash on a shipping target — strictly worse, and a direct *"crashed is not acceptable"* violation. So the right change was to **not** add the cancel, and instead replace the vague "deferred" comment with a sourced explanation of why we deliberately leave a timed-out transfer alone (it self-heals: next tick re-encodes and re-transmits; a still-busy channel just fails its `rmt_transmit` cleanly, and `started[]` skips the wait). Lesson: a deferred-improvement note is a hypothesis, not a spec — verify the improvement is real *and* safe on every target before implementing it; sometimes the finished work is "document why the current code is already right." + +## Pin defaults: assign one only when it cannot do harm + +A mic-less **classic ESP32** boot-looped (TG1WDT_SYS_RESET at ~736 ms, no panic backtrace — a silent hang). Bisect: clean-built the known-good commit (still looped → recent driver work innocent), then disabled the AudioModule wiring in `main.cpp` → booted clean → **AudioModule was the cause.** Root cause: AudioModule was **auto-wired** (`addChild` + `markWiredByCode()` in `main.cpp`, gated on `platform::hasI2sMic`), so on every boot it ran `setup()` → `reinit()` → `platform::audioMicInit()` → the IDF `i2s_channel_enable()`, which on the classic's older I²S driver **blocks forever** when no mic is clocking the pins. The watchdog fired on the stuck init. (The P4 was never affected: its newer I²S either returns a silent frame or fails cleanly without blocking — same mic-less condition, different driver behaviour. Two independent code paths, one symptom only on classic.) + +**The fix, per the product owner, was a design fix not a band-aid:** (1) **don't auto-wire AudioModule** — register it in the factory (`registerType`) so it's user-addable like an effect, but only when the user with a mic adds it; (2) **default the mic pins to unset (0)**, not to bench values; (3) `reinit()` **no-ops on any unset pin** (`if (wsPin==0||sdPin==0||sckPin==0) { setStatus("set …"); return; }`) so even an added-but-unconfigured module never touches I²S. Classic then boots 191 FPS, 0 WDT resets, and an added mic works once its real GPIOs are entered. + +**The generalisable rule the product owner drew from it: _assign a pin default only when it cannot do harm._** The test is *who fixes the pin*: +- **Chip-/board-fixed pins → default them** (and you *must*): the RMII **Ethernet** pin map is silicon-/PCB-wired, so a default cannot do harm — and *omitting* it does, because a no-WiFi board with un-defaulted Ethernet pins can never connect to be configured (a chicken-and-egg lockout). This is why `platform::ethPins` is a compile-time-per-target constant, never a user-blank control — and it stays that way. +- **User-soldered pins → leave them empty**: a MEMS mic or an LED strand goes wherever the user ran the wire, so any default is a guess that can drive a pin the user committed to something else. Empty until set; idle with a "set pins" status meanwhile (the robustness rule: degraded is fine, crashed is not). + +The LED drivers (Rmt/Lcd/Parlio) are the same Device-level case — user-soldered, so their pin defaults follow the same rule. This rule is the runtime face of a three-level **MCU → Board → Device** config-provenance model (backlogged): a pin may be defaulted only at the level that actually fixes it, and the empty Device-level defaults are the correct baseline a saved device profile later *fills* rather than *overrides*. Lesson: a hard-coded pin default is a claim about the user's hardware — make that claim only when the hardware, not the user's soldering iron, decides the pin; and never auto-run a peripheral whose init can block on absent hardware. + +## Live reconfiguration falls out of the prepare-pass for free — MoonLight's "initless" goal, a different mechanism + +projectMM has a property most LED-controller firmware lacks: **every module reconfigures live the instant a control changes — pins, leds-per-pin, output protocol, mic pin/rate — with no reboot, immediately reactive on the next render tick.** The design note for *why* this exists lives in [architecture.md § Live reconfiguration](../architecture.md#live-reconfiguration-every-change-applies-without-a-reboot); the lineage and the *how-it-differs* are the lesson worth keeping here. + +**The lineage is MoonLight's "initless drivers."** The product owner's earlier project ([MoonLight nodes.md § Initless drivers](https://github.com/MoonModules/MoonLight/blob/main/docs/develop/nodes.md)) set the same no-reboot goal at the LED-driver level, named *initless*: a driver with **no `addLeds` (FastLED) / `initLed` (Parallel LED Driver) step** — it reads a mutable Context at `show()` time, so pin allocation, leds-per-pin, RGB/RGBW and light type all change live without a restart or recompile. + +**projectMM reaches the same outcome by a different mechanism, so the word doesn't transfer.** Our drivers *do* have an explicit rebuild — `RmtLedDriver::reinit()` re-creates the RMT channels, the i80/Parlio drivers rebuild the DMA bus — so they are not "initless" in MoonLight's no-`initLed` sense. What makes the behaviour universal here is that the rebuild is driven by the **generic tier-3 `onBuildState()` sweep** ([§ Event triggering](../architecture.md#event-triggering-between-modules)), not hand-built per driver: any module that returns `true` from `controlChangeTriggersBuildState` inherits live-reconfig for free, which is why it spans drivers, the audio peripheral, effects, layouts, modifiers and network I/O alike. Lesson: credit the lineage for the *idea* (MoonLight's initless drivers), but name the property by what the user sees (*live, no-reboot reconfiguration*) when the mechanism differs — overloading a prior project's term onto a different implementation misleads. And: a generic prepare-pass buys breadth a per-driver technique can't — the same three tiers that rebuild a mapping LUT also re-target a GPIO, so the property generalised itself. diff --git a/docs/install/boards.json b/docs/install/boards.json index d98cf2e8..e7d30b63 100644 --- a/docs/install/boards.json +++ b/docs/install/boards.json @@ -161,5 +161,14 @@ "controls": { "Board": { "board": "SE 16 V1" } } + }, + { + "name": "Waveshare ESP32-P4-NANO", + "chip": "ESP32-P4", + "firmwares": ["esp32p4-eth"], + "default_firmware": "esp32p4-eth", + "controls": { + "Board": { "board": "Waveshare ESP32-P4-NANO" } + } } ] diff --git a/docs/install/index.html b/docs/install/index.html index 4f35bcea..09eab70b 100644 --- a/docs/install/index.html +++ b/docs/install/index.html @@ -1363,6 +1363,7 @@

Serial monitor

if (_monitor.port) await closeMonitor(); const localUrl = toLocalUrl(manifestUrl); const board = installPicker.getSelectedBoard(); + const txPower = installPicker.getSelectedBoardTxPower(); openModal(board ? `Installing projectMM on ${board}` : `Installing ${firmware}`); showSection("connecting"); document.getElementById("connecting-detail").textContent = ""; @@ -1372,6 +1373,7 @@

Serial monitor

port: pickedPort, manifestUrl: localUrl, board, + txPower, eraseBefore, onProgress: handleProgress, uiWaitForCreds, diff --git a/docs/install/install-orchestrator.js b/docs/install/install-orchestrator.js index ae284431..d6564afa 100644 --- a/docs/install/install-orchestrator.js +++ b/docs/install/install-orchestrator.js @@ -37,6 +37,15 @@ import { ImprovSerial } from "https://unpkg.com/improv-wifi-serial-sdk@2.5.0/dis // src/platform/esp32/platform_esp32_improv.cpp. const IMPROV_CMD_SET_BOARD = 0xFE; +// SET_TX_POWER vendor RPC command ID — the pre-association TX-power cap for +// boards whose LDO browns out at full power (LOLIN S3/S2). Their boards.json +// cap (controls.Network.txPowerSetting) used to arrive only via the HTTP +// fan-out AFTER the device was online, which a browning-out board can never +// reach: it fails WiFi auth at 20 dBm first (proven on the bench 2026-06-10). +// Sent BEFORE provisioning so the very first association runs capped. +// Matches the device-side handler at src/platform/esp32/platform_esp32_improv.cpp. +const IMPROV_CMD_SET_TX_POWER = 0xFD; + // Improv frame type for RPC commands (matches src/core/ImprovFrame.h). const IMPROV_FRAME_TYPE_RPC = 0x03; @@ -166,6 +175,22 @@ async function sendSetBoardFrame(port, board) { } } +// Sends the SET_TX_POWER frame ([0xFD][1][dBm]) on a port we own — called +// BEFORE ImprovSerial takes the port's locks, so no close/reopen dance is +// needed. Fire-and-forget like SET_BOARD: the device acks with RpcResponse +// we don't read; the HTTP fan-out later re-applies the same boards.json +// value as the late fallback. +async function sendSetTxPowerFrame(port, dBm) { + const frame = buildImprovFrame(IMPROV_FRAME_TYPE_RPC, + new Uint8Array([IMPROV_CMD_SET_TX_POWER, 1, dBm & 0xFF])); + const writer = port.writable.getWriter(); + try { + await writer.write(frame); + } finally { + writer.releaseLock(); + } +} + // --------------------------------------------------------------------------- // HTTP fallback for Improv-less paths (alreadyOnline, esp32-eth) // --------------------------------------------------------------------------- @@ -304,6 +329,11 @@ export const installer = { * @param {string} opts.manifestUrl - URL to an ESP Web Tools manifest * @param {string} [opts.board] - board name from boards.json to push * via SET_BOARD after provisioning. Omit / empty for "(any board)". + * @param {number|null} [opts.txPower] - boards.json + * controls.Network.txPowerSetting for the picked board (whole dBm). + * When set, the SET_TX_POWER vendor RPC is pushed BEFORE provisioning + * so brown-out-prone boards associate at the capped power. Omit / + * null when the board has no cap. * @param {boolean} [opts.eraseBefore=false] - when true, eraseFlash() * before writeFlash. Wipes the entire chip including LittleFS (saved * WiFi credentials, board name). Adds ~12 s. Default false because @@ -311,8 +341,8 @@ export const installer = { * persistent state to survive a firmware bump. * @param {(stage: string, detail?: object) => void} opts.onProgress * Stages: request-port, connect-flash, fetch-firmware, erase, - * flash, reboot, connect-improv, wifi-creds-form, provisioning, - * set-board, done. flash also carries { pct }. connect-flash carries + * flash, reboot, connect-improv, set-tx-power, wifi-creds-form, + * provisioning, set-board, done. flash also carries { pct }. connect-flash carries * { chipName } once detection succeeds. * @param {() => Promise<{ssid: string, password: string}>} opts.uiWaitForCreds * Host page resolves this when the user fills in the WiFi form. @@ -369,7 +399,7 @@ export const installer = { * omitted/null OR when opening this handle fails (stale grant after * the device was unplugged and replugged). */ - async start({ manifestUrl, board, eraseBefore = false, port: prePickedPort, + async start({ manifestUrl, board, txPower = null, eraseBefore = false, port: prePickedPort, onProgress, uiWaitForCreds, uiWaitForIp, uiShowNeedsIpRetrying, uiWaitForPortRetry, onSuccess, onError, onLog }) { @@ -627,6 +657,16 @@ export const installer = { port = await navigator.serial.requestPort({}); await port.open({ baudRate: 115200 }); } + // Pre-association TX-power cap (LOLIN brown-out fix): push it + // while we still own the port, before ImprovSerial locks it. + // The device applies + persists it within a second — long + // before the user finishes the WiFi form below. + if (txPower != null) { + trackProgress("set-tx-power"); + if (onLog) onLog(`[orchestrator] SET_TX_POWER ${txPower} dBm (boards.json cap)`); + await sendSetTxPowerFrame(port, txPower); + await new Promise(r => setTimeout(r, 200)); + } improvClient = new ImprovSerial(port, improvLogger); // ImprovSerial's initialize() throws "Improv Wi-Fi Serial not // detected" in two distinct cases that look identical to the SDK @@ -744,6 +784,12 @@ export const installer = { // unlocked state. if (uiShowNeedsIpRetrying) uiShowNeedsIpRetrying(true); await new Promise(r => setTimeout(r, 250)); + // Re-push the TX-power cap on every retry: a slow-booting + // board may have missed the first frame entirely. + if (txPower != null) { + try { await sendSetTxPowerFrame(port, txPower); } catch (_) { /* best-effort */ } + await new Promise(r => setTimeout(r, 200)); + } try { improvClient = new ImprovSerial(port, improvLogger); await improvClient.initialize(); diff --git a/docs/landing/index.html b/docs/landing/index.html new file mode 100644 index 00000000..b292b1b8 --- /dev/null +++ b/docs/landing/index.html @@ -0,0 +1,73 @@ + + + + + + projectMM + + + + + +
+

projectMM

+

High-performance LED & DMX lighting control for ESP32 and beyond.

+ + ⚡ Flash an ESP32 from your browser + + + +

Web installer works in Chrome & Edge (Web Serial). No download required.

+
+ + diff --git a/docs/moonmodules/core/AudioModule.md b/docs/moonmodules/core/AudioModule.md new file mode 100644 index 00000000..72083f2c --- /dev/null +++ b/docs/moonmodules/core/AudioModule.md @@ -0,0 +1,113 @@ +# 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/AudioVolumeEffect.md) and [AudioSpectrumEffect](../light/effects/AudioSpectrumEffect.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. 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 (0)** 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: + +| Control | Default | Pin | +|---|---|---| +| `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.) +- **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`: read-only live sound level (updates each second). +- `peakHz`: read-only dominant frequency (updates each second). + +## 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. + +## 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/backlog.md#sensors-and-audio-reactive-input) 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 § audio follow-ups](../../backlog/backlog.md#sensors-and-audio-reactive-input). + +## 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/FilesystemModule.md b/docs/moonmodules/core/FilesystemModule.md index 2cee1694..a8d37a6a 100644 --- a/docs/moonmodules/core/FilesystemModule.md +++ b/docs/moonmodules/core/FilesystemModule.md @@ -18,7 +18,7 @@ One flat JSON file per top-level module under `/.config/`: "0.type":"NoiseEffect","0.scale":12,"0.bpm":60, "0.enabled":true,...} Drivers.json → {"enabled":true, - "0.type":"ArtNetSendDriver","0.ip":"192.168.1.70", + "0.type":"NetworkSendDriver","0.ip":"192.168.1.70", "0.fps":50,"0.enabled":true,...} ``` diff --git a/docs/moonmodules/core/ImprovProvisioningModule.md b/docs/moonmodules/core/ImprovProvisioningModule.md index 4ece0bd0..9f69f4bd 100644 --- a/docs/moonmodules/core/ImprovProvisioningModule.md +++ b/docs/moonmodules/core/ImprovProvisioningModule.md @@ -14,18 +14,18 @@ The browser flow runs immediately after a Web Serial flash (ESP Web Tools recogn ## ESP32-S3 USB-port footnote -The ESP32-S3-DevKitC-1 has **two USB ports**. Improv only works on the silkscreen-labelled UART port (UART0 routed through the on-board USB-to-UART bridge). The native USB-Serial-JTAG port is a different hardware block and is not supported by the Improv listener. - -If your S3 board only exposes the native USB-CDC port (some breakout boards), fall back to the AP-mode flow: device boots a SoftAP at `4.3.2.1`, join from a phone, enter credentials. +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 (LOLIN S3 N16R8 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: . The on-device implementation supports four RPC commands: +Both transports speak the same Improv-WiFi serial protocol — frames of `IMPROV` + version byte + type + length + payload + checksum. Full protocol details: . 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:///`) or `ERROR_UNABLE_TO_CONNECT`. +- `SET_BOARD` (vendor, `0xFE`) — payload `[str_len][board name]`; persists the physical-board name into BoardModule. Sent by the web installer after provisioning. +- `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 (LOLIN S3/S2): their `boards.json` cap normally arrives over HTTP *after* the device is online — which a browning-out board can never reach, since it fails WiFi auth at 20 dBm first. `improv_provision.py --tx-power 8` (and the MoonDeck flow) sends this ahead of the credentials; error `0x81` on an out-of-range value. `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. diff --git a/docs/moonmodules/core/SystemModule.md b/docs/moonmodules/core/SystemModule.md index ed3aabc3..3f01abfd 100644 --- a/docs/moonmodules/core/SystemModule.md +++ b/docs/moonmodules/core/SystemModule.md @@ -23,6 +23,7 @@ System-level diagnostics and device identity. Always loaded, always visible in t - `firmware` (read-only) — build-time firmware variant key from `src/core/build_info.h` (`MM_FIRMWARE_NAME`): `esp32`, `esp32-eth`, `esp32-eth-wifi`, `esp32s3-n16r8` for the shipped firmware variants; `desktop-macos-arm64` / `desktop-windows-x64` for packaged desktop binaries; `desktop-dev` for unpackaged local desktop builds. Identifies which release asset matches the device — the same key appears in the firmware filenames published by `release.yml`. "Firmware" is the compiled binary; the physical board the firmware runs on lives on the [BoardModule](BoardModule.md) child (code-wired in `main.cpp`, mirrors how Improv sits under Network). - `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 - `firmwarePartition` (read-only, progress) — current app image size / total firmware partition size. Distinct from the `firmware` string control above (which is the build variant identifier); this is how full the partition is. Renamed from the previous shared `firmware` name to avoid the collision that broke `controls.find(c => c.name === "firmware")` callers — see the comment at the binding in [SystemModule.h](../../../src/core/SystemModule.h). - `psram` (read-only, progress) — used / total PSRAM (only if present) diff --git a/docs/moonmodules/light/Drivers.md b/docs/moonmodules/light/Drivers.md index 88642809..b35355ed 100644 --- a/docs/moonmodules/light/Drivers.md +++ b/docs/moonmodules/light/Drivers.md @@ -21,7 +21,7 @@ The Drivers container owns the shared output-correction state and exposes two co | 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`. RGBW presets make each driver emit 4 channels per light with white derived as `min(R,G,B)` from the (brightness-scaled) RGB. | +| `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. | 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 currently sees the same blended output of the active layer; per-driver layer assignment is a [backlog](../../backlog/backlog.md) item that lands with multi-layer composition. diff --git a/docs/moonmodules/light/drivers/ArtNetSendDriver.md b/docs/moonmodules/light/drivers/ArtNetSendDriver.md deleted file mode 100644 index bf46ced8..00000000 --- a/docs/moonmodules/light/drivers/ArtNetSendDriver.md +++ /dev/null @@ -1,55 +0,0 @@ -# ArtNet Send Driver - -![ArtNetSendDriver controls](../../../assets/screenshots/ArtNetSendDriver.png) - -Output driver. Reads from the Drivers container's output buffer and sends ArtNet DMX packets over UDP. The driver doesn't care whether the buffer is a separate composed buffer or a shared Layer buffer — it reads from whatever the Drivers container provides. - -The UDP socket is `connect()`-bound to the destination in `setup()`, so each per-universe `sendTo()` skips the address parse + route lookup — a measurable saving when a frame spans dozens of universes (16,384 lights = 97 universes). See [performance.md](../../../performance.md) "ArtNet UDP send cost". - -**Synchronous send (throughput-bound at large grids).** The send is synchronous in the render loop — one UDP packet per universe. A full 128×128 frame is ~97 universes (~50 KB); pushing that through the ESP32 TX path takes real wall-clock time (measured ~35 ms over Ethernet, ~90 ms over WiFi) that is charged to the render tick, so ArtNet dominates the tick at large grids. This is a transport throughput limit, not a code path that a non-blocking socket can shed: lwIP blocks UDP TX in the netif/driver layer below the socket API, so neither `O_NONBLOCK` nor `MSG_DONTWAIT` makes a full-frame send return early (verified on hardware). For high frame rates at large grids, use Ethernet over WiFi, or a smaller grid. See [performance.md](../../../performance.md) for the measured per-transport send cost. - -## Controls - -- `ip` (ipv4, default "192.168.1.70") — destination IP address. Stored as 4 octets device-side (`uint8_t[4]`), formatted to a dotted-quad string only at the wire boundary. See [coding-standards.md § Prefer integers](../../../coding-standards.md#prefer-integers-store-values-in-their-native-shape). -- `universe_start` (uint16_t, default 0, range 0-32767) — first ArtNet universe -- `fps` (uint8_t, default 50, range 1-120) — send frame rate limit. Critical: without FPS limiting, receivers drop packets. - -## ArtNet Packet Format - -Art-Net DMX (OpDmx = 0x5000): -- Header: "Art-Net\0" (8 bytes), OpCode (2), ProtVer (2), Sequence (1), Physical (1), Universe (2), Length (2) -- Data: channel bytes (max 512 = 170 RGB lights per universe) - -Sequence field increments per frame (0-255, wrapping) so receivers can detect packet reordering. - -## Universe Splitting - -For >170 RGB lights, data is split across consecutive universes starting from `universe_start`. - -## Socket - -Opened in setup(), closed in teardown(). Uses platform UDP abstraction. Not a hot-path allocation. - -## Tests - -[Unit tests: ArtNetSendDriver](../../../tests/unit-tests.md#artnetsenddriver) — header format, byte order, universe splitting. - -[Scenario: scenario_Layer_base_pipeline](../../../tests/scenario-tests.md#scenario_layer_base_pipeline) — full pipeline with ArtNet output, performance bounds. - -## Prior art - -### MoonLight — D_NetworkOut ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Drivers/D_NetworkOut.h)) - -Supports ArtNet, E1.31, and DDP output. Multi-protocol in one driver. - -### projectMM v1 — ArtNetOutModule ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/drivers/ArtNetOutModule.h)) - -Controls: universe_start (slider 0-255), ip (text). Platform UDP abstraction. - -### projectMM v2 — ArtnetOutModule ([source](https://github.com/ewowi/projectMM-v2/blob/main/src/modules/lights/ArtnetOutModule.h)) - -Uses PalUdp abstraction. ADR 0005 teardown safety via DataBuffer invalidation. - -## Source - -[ArtNetSendDriver.h](../../../../src/light/drivers/ArtNetSendDriver.h) diff --git a/docs/moonmodules/light/drivers/LcdLedDriver.md b/docs/moonmodules/light/drivers/LcdLedDriver.md new file mode 100644 index 00000000..d9f32557 --- /dev/null +++ b/docs/moonmodules/light/drivers/LcdLedDriver.md @@ -0,0 +1,46 @@ +# LCD LED Driver + +Parallel [WS2812B](https://cdn-shop.adafruit.com/datasheets/WS2812B.pdf) output on the **ESP32-S3** over the [LCD_CAM](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-reference/peripherals/lcd/index.html) peripheral: up to **8 strands clock out simultaneously**, one GPIO per strand, all fed by a single autonomous DMA transfer. The S3's scale path — where the [RMT driver](RmtLedDriver.md) tops out at 4 channels on the S3, this drives 8 lanes for the wall time of one. Reads the Drivers container's buffer, applies the shared [Correction](Correction.md) per light, and bit-transposes corrected bytes across the lanes. + +## 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`). + +## Controls + +- `pins` (text, default empty) — comma-separated data GPIOs, one lane each, **exactly 8** (the i80 peripheral configures every data line of the bus width and rejects partial sets, so all 8 GPIOs are claimed even when fewer strands are wired). Empty by default (the strand is user-soldered, so no pin is assumed — the driver idles until set); a known-good LOLIN S3 set is `1,2,4,5,6,7,8,9`, which clears the octal-PSRAM pins (26–37), USB (19/20) and strapping pins. Changing it re-creates the i80 bus **live, no reboot** ([§ Live reconfiguration](../../../architecture.md#live-reconfiguration-every-change-applies-without-a-reboot)). +- `ledsPerPin` (text, default empty) — lights per lane, matched by position; empty = even split. To drive fewer than 8 strands, give the unused lanes `0` (or list only the used lanes' counts summing to the grid size — the remainder lanes get 0 and idle LOW). +- `clockPin` (uint16_t, default 10) — the i80 bus WR line. The peripheral *requires* it on a real GPIO (the IDF i80 bus rejects `wr_gpio_num < 0`); WS2812 strands ignore the waveform. Peripheral-fixed (not user-strand wiring), so it keeps a sensible overridable default — point it at any otherwise-free GPIO if 10 is taken. +- `dcPin` (uint16_t, default 11) — the i80 data/command line, same story: required by the peripheral (`dc_gpio_num < 0` is rejected), unused by the LEDs, overridable default. +- `loopbackTest` (bool) — one-shot signal self-test: jumper the **first** pin in `pins` to `loopbackRxPin`, tick the box; the driver transmits its **real frame** (full size, real DMA chain, repeated back to back like the render loop) with a known pattern in every row, captures the whole frame back with an RMT RX channel (the increment-1 rig reused — RMT receive is transmitter-agnostic) and verifies every bit. Result lands in the status field; on failure it names the first corrupted light. +- `loopbackRxPin` (uint16_t, default unset) — the RX pin for the self-test; set it when you wire the jumper (the bench used 12). Shown only while `loopbackTest` is on. + +## 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 + +Added as a child of the `Drivers` container in `main.cpp` under `if constexpr (platform::lcdLanes > 0)` (SOC-derived: the S3 among current targets), wired by code like its siblings. 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. + +## Tests + +Full case list in the generated [unit tests § LcdLedDriver](../../../tests/unit-tests.md#lcdleddriver) (regenerated from the test files, never drifts). What's covered: + +- **Encoder (CI, host):** byte-exact 3-slot triplets — transpose across lanes, MSB-first, the unequal-lane idle-LOW rule, GRB via Correction, RGBW rows. +- **Driver (CI, host):** lane slicing (including unequal leds-per-lane), frame-byte math (RGBW growth, alignment rounding), bad-pin status + recovery, the exactly-8-pins rule, the empty-default idle (no GPIO claimed until pins are set), zero-grid robustness, teardown. +- **Hardware:** the loopback self-test above (jumper), and tick-scaling across grid sizes proves frames really clock out. + +## Prior art + +The LCD_CAM-for-WS2812 repurposing was discovered by **Adafruit (Phil Burgess)** ([ESP32uesday, June 2022](https://blog.adafruit.com/2022/06/14/esp32uesday-hacking-the-esp32-s3-lcd-peripheral/)) and matured in **hpwit's I2SClockless driver lineage** ([I2SClocklessVirtualLedDriver](https://github.com/hpwit/I2SClocklessVirtualLedDriver)) and **FastLED's S3 clockless-LCD driver** — studied via the project's [LED driver analyses](../../../backlog/leddriver-analysis-top-down.md) for the lessons, never copied. This driver differs from that lineage by pre-encoding the whole frame (no ISR-refilled ring), trading a larger buffer for the absence of refill deadlines. + +## Source + +[LcdLedDriver.h](../../../../src/light/drivers/LcdLedDriver.h) diff --git a/docs/moonmodules/light/drivers/NetworkSendDriver.md b/docs/moonmodules/light/drivers/NetworkSendDriver.md new file mode 100644 index 00000000..2eb04a9c --- /dev/null +++ b/docs/moonmodules/light/drivers/NetworkSendDriver.md @@ -0,0 +1,51 @@ +# Network Send Driver + +![NetworkSendDriver controls](../../../assets/screenshots/NetworkSendDriver.png) + +Streams the light buffer over UDP in one of three industry protocols, selected by a control: **[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)** (the ANSI E1.31 streaming-ACN standard), or **[DDP](http://www.3waylabs.com/ddp/)** (Distributed Display Protocol). Reads the Drivers container's buffer, applies the shared [Correction](Correction.md) (brightness / channel order / RGBW white) per light, chunks the corrected bytes per the selected protocol, and sends the whole frame as one burst at the configured rate. The single-node-multiple-protocols shape follows MoonLight's D_NetworkOut (architecture studied, not copied). Compatible with industry receivers — pixel controllers (Falcon, Advatek), xLights, LedFx, and ArtNet-controllable software. + +## Controls + +- `protocol` (select: ArtNet / E1.31 / DDP, default ArtNet) — the wire protocol; the destination port follows it automatically (6454 / 5568 / 4048). Changing it re-targets the socket **live, no reboot** ([§ Live reconfiguration](../../../architecture.md#live-reconfiguration-every-change-applies-without-a-reboot)) — switch output protocol on a running device mid-show. +- `ip` (IPv4, default 192.168.1.70) — unicast destination. Changing it re-binds live; E1.31 multicast is deliberately not implemented (see Interop below). +- `universe_start` (uint16_t, default 0) — first universe for ArtNet and E1.31; DDP is byte-addressed and ignores it. +- `fps` (uint8_t, default 50, range 1-120) — frame rate limit. Without it the loop would re-send on every render tick; receivers expect a steady frame cadence. + +## 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 only.** E1.31 multicast (group 239.255.x.x) is deferred — the platform has no IGMP join yet; MoonLight ships unicast-only too. See the backlog entry. + +## 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/backlog.md)). FPS limiting plus the all-universes-in-one-burst shape is what receivers expect. + +## Cross-domain wiring + +Added as a child of the `Drivers` container in `main.cpp`, wired by code (a persistence load can't drop it): receives `setSourceBuffer` / `setCorrection` from `Drivers::passBufferToDrivers` 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. + +## Tests + +[Unit tests: NetworkSendDriver](../../../tests/unit-tests.md#networksenddriver) — exact wire layouts for all three protocols (the byte offsets strict receivers validate), universe splitting, and the no-allocation-in-loop contract per protocol path. + +Live tier: `uv run scripts/scenario/run_network_live.py` ([MoonDeck.md § run_network_live](../../../../scripts/MoonDeck.md#run_network_live)) relays between real boards with the protocol control cycled round-robin. + +## Prior art + +MoonLight's D_NetworkOut (ArtNet/E1.31/DDP in one node) and the v1/v2 ArtNet senders; protocol specs: Art-Net 4 (Artistic Licence), ANSI E1.31-2016, DDP (3waylabs). Studied for the lessons, never copied. + +## Source + +[NetworkSendDriver.h](../../../../src/light/drivers/NetworkSendDriver.h) diff --git a/docs/moonmodules/light/drivers/ParlioLedDriver.md b/docs/moonmodules/light/drivers/ParlioLedDriver.md new file mode 100644 index 00000000..ced978d6 --- /dev/null +++ b/docs/moonmodules/light/drivers/ParlioLedDriver.md @@ -0,0 +1,64 @@ +# Parlio LED Driver + +Parallel [WS2812B](https://cdn-shop.adafruit.com/datasheets/WS2812B.pdf) output on the **[ESP32-P4](https://www.espressif.com/en/products/socs/esp32-p4)** over the [Parlio (Parallel IO)](https://docs.espressif.com/projects/esp-idf/en/stable/esp32p4/api-reference/peripherals/parlio/index.html) TX peripheral: up to **8 strands clock out simultaneously**, one GPIO per strand, all fed by a single autonomous DMA transfer. The P4's scale path — the sibling of the [LCD driver](LcdLedDriver.md) on the S3. Reads the Drivers container's buffer, applies the shared [Correction](Correction.md) per light, and bit-transposes corrected bytes across the lanes. + +The P4 actually carries **all three** LED peripherals — [RMT](RmtLedDriver.md) (4 DMA-backed channels), [LCD_CAM i80](LcdLedDriver.md) (8 lanes), and Parlio (this driver) — and all three drivers auto-wire there off their SOC-capability gates. Parlio is the **preferred** parallel path on the P4 (its dedicated parallel-output engine), with RMT the easy single-strand option and LCD_CAM the other parallel route; the user picks per install by enabling the driver that matches their wiring. + +### Running all three at once — the P4 pin budget + +The three drivers are independent children of the `Drivers` container: each has its own `pins`, each reads the same logical buffer, and they're separate peripherals (RMT, LCD_CAM, and Parlio are distinct engines), so **they can all transmit simultaneously, each on its own GPIOs**. The combined output-pin ceiling, with the drivers' current per-chip caps: + +| Driver | Pins (current cap) | Peripheral max | +|---|---|---| +| RMT | 4 (the P4 has 4 TX channels) | 4 | +| LCD_CAM i80 | 8 | 16 | +| Parlio | 8 (any 1–8) | 16 | +| **Total simultaneous** | **20** | up to 36 if the LCD/Parlio caps were raised | + +So **up to 20 parallel WS2812 strands** at once on the P4 today. The Waveshare P4-NANO physically exposes exactly 20 clear GPIOs (`20–27, 32–33, 39–48`, after Ethernet, the C6 SDIO, I2C and the strapping pins), so that board can in principle drive all 20 — but two honest limits apply beyond pin count: (1) **throughput is bounded by internal DMA RAM and the render tick, not pins** — the per-frame DMA buffers (~72 B/RGB light per parallel driver) and the encode time set the real ceiling on *long* strands, so 20 short strands is very different from 20 long ones; and (2) raising the LCD/Parlio caps to 16 (a constant change) only helps where a board breaks out that many free pins, which the P4-NANO does not. For most installs one parallel driver (8 lanes) is plenty; the multi-driver headroom is there for unusually wide, short-strand layouts. + +It is the [LCD driver](LcdLedDriver.md) shape with two simplifications, because Parlio is a simpler peripheral than the LCD_CAM i80 bus: + +- **No clock/dc pins.** The i80 bus needs two GPIOs (WR + DC) on real pins even though WS2812 ignores them (the IDF i80 layer rejects `wr/dc < 0`); Parlio generates the pixel clock itself (`clk_out_gpio_num = GPIO_NUM_NC`) and has no command/data phase, so there are none. (The LCD driver keeps an overridable default for its two; dropping them there would need a direct-LCD_CAM driver — backlogged.) +- **No exactly-8-pins rule.** The i80 layer rejects a partial bus (every data line must be a real GPIO), so the LCD driver demands exactly 8 pins. Parlio takes the data GPIOs directly and runs on **1–8 lanes** — whatever `pins` names. + +## 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`). + +## Controls + +- `pins` (text, default empty) — comma-separated data GPIOs, one lane each, **1 to 8** (no all-pins rule). Empty by default (the strand is user-soldered, so no pin is assumed — the driver idles until set). Choosing pins on the P4-NANO, **avoid**: STRAPPING pins **34–38** (boot-mode control — driving these can break boot, never use them for output), Ethernet RMII (28–31, 49–52), the ESP32-C6 SDIO (14–19, 54), and I2C (7–8). The clear GPIOs are **20–27, 32–33, 39–48**; a known-good bench set is `20,21,22,23,24,25,26,27`. Add pins for parallel strips. Changing it re-creates the Parlio TX unit **live, no reboot** ([§ Live reconfiguration](../../../architecture.md#live-reconfiguration-every-change-applies-without-a-reboot)). The loopback self-test transmits on the **first** pin. +- `ledsPerPin` (text, default empty) — lights per lane, matched by position; empty = even split over the wired lanes (all lights on the first lane when one pin is set), remainder to the last lane. Same semantics as the RMT/LCD drivers. +- `loopbackTest` (bool) — one-shot **whole-frame** signal self-test: TX on the first pin in `pins`, RX on `loopbackRxPin`. It builds the real frame (test pattern in every row on lane 0), transmits it back to back like the render loop through a private Parlio TX unit, captures the entire frame on the RX pin (RMT-RX with the P4's DMA backend — the [same `rmtWs2812RxCapture`](RmtLedDriver.md#loopback-self-test-on-device) the RMT/LCD rigs use, transmitter-agnostic), and bit-verifies every WS2812 bit. The verdict lands in the status field: `loopback PASS`, `loopback FAIL: bad bit N/M (light K)`, or `loopback: jumper not detected` (a plain-GPIO continuity pre-check runs first). The strip on lane 0 flickers once during the run; normal output resumes after. +- `loopbackRxPin` (uint16_t, default unset) — the RX pin for the self-test; set it when you wire the jumper (the bench used 33, jumper GPIO 32 → 33, both strapping-safe). Shown only while `loopbackTest` is on. + +## 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 + +Added as a child of the `Drivers` container in `main.cpp` under `if constexpr (platform::parlioLanes > 0)` (SOC-derived: the P4 among current targets), wired by code like its siblings. 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. + +## Tests + +Full case list in the generated [unit tests § ParlioLedDriver](../../../tests/unit-tests.md#parlioleddriver) (regenerated from the test files, never drifts). What's covered: + +- **Encoder (CI, host):** shared with the LCD driver — the 3-slot byte layout is covered under [LcdLedDriver](LcdLedDriver.md#tests); not re-tested here. +- **Driver (CI, host):** lane slicing (including unequal leds-per-lane), frame-byte math (RGBW growth, alignment rounding, latch pad), the **1–8 lanes accepted** rule (the Parlio-vs-i80 difference), over-8 rejection, bad-pin status + recovery, the empty-default idle (no GPIO claimed until pins are set), zero-grid + loop() crash-safety, teardown. +- **Hardware:** tick-scaling across grid sizes proves frames clock out; the whole-frame loopback self-test (jumper GPIO 32 → 33) bit-verifies the wire signal on the P4. + +## Prior art + +The P4 **Parlio** peripheral is Espressif's dedicated parallel-output engine (`esp_driver_parlio`). The parallel-WS2812 technique is the same studied for the LCD driver — **hpwit's I2SClockless lineage** and **FastLED's parallel clockless drivers** — read for the lessons via the project's [LED driver analyses](../../../backlog/leddriver-analysis-top-down.md), never copied. Like the LCD driver, this one pre-encodes the whole frame (no ISR-refilled ring), trading a larger buffer for the absence of refill deadlines. + +## Source + +[ParlioLedDriver.h](../../../../src/light/drivers/ParlioLedDriver.h) diff --git a/docs/moonmodules/light/drivers/RmtLedDriver.md b/docs/moonmodules/light/drivers/RmtLedDriver.md new file mode 100644 index 00000000..0427197a --- /dev/null +++ b/docs/moonmodules/light/drivers/RmtLedDriver.md @@ -0,0 +1,74 @@ +# RMT LED Driver + +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 or more strands, one GPIO and one RMT TX channel per strand. Reads the Drivers container's buffer, applies the shared [Correction](Correction.md) (brightness / channel order / RGBW white) per light, and emits the WS2812 1-wire signal. Runs on any chip whose RMT peripheral has TX channels: classic ESP32 (8 channels), ESP32-S3 (4 channels), and ESP32-P4 (4 channels, DMA-backed). On desktop the RMT platform seam is a no-op and the driver is inert. + +## 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. + +## 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. A dedicated core-1 driver task (the WiFi-glitch mitigation from the [LED driver analysis](../../../backlog/leddriver-analysis-top-down.md) § 7.2) and per-module core affinity are a later increment; until then, large strands or WiFi-interrupt-sensitive installs may show timing artifacts. See the [increment-2 plan](../../../backlog/leddriver-increment-2-plan.md). + +## Controls + +- `pins` (text, default empty) — comma-separated data / TX GPIO list, e.g. `18,17,16`. Empty by default (the strand is user-soldered, so no pin is assumed — the driver idles until set; the bench used `18`). One RMT TX channel per pin: up to 8 on classic ESP32, 4 on the S3 and P4 (exceeding the chip's limit, a bad token, or a duplicate pin puts an error in the status field and the driver idles). Changing it re-initialises the channels **live, no reboot** ([§ Live reconfiguration](../../../architecture.md#live-reconfiguration-every-change-applies-without-a-reboot)) — edit pins, counts, or colour order on a running device and the next frame uses them. The loopback self-test transmits on the **first** pin in the list. +- `ledsPerPin` (text, default empty) — comma-separated lights-per-pin, e.g. `100,100,50`, matched to `pins` by position. May be empty or shorter than `pins`; see Buffer slicing above. +- `loopbackRxPin` (uint16_t, default unset) — the RX pin for the loopback self-test; set it when you wire the jumper (the bench used 5). Jumper it to the **first** pin in `pins` to run the test. Shown only while `loopbackTest` is on. +- `loopbackTest` (bool) — a persistent on/off mode for the RMT TX→RX loopback self-test (see Self-test below). While it is on, the test re-runs whenever a relevant control changes (`pins`, `loopbackRxPin`, `loopbackFrame`), so the pins can be set in any order and the result always reflects the current wiring; the verdict lands in the module's status field. Turning it off clears the verdict. +- `loopbackFrame` (bool) — whole-frame variant of the self-test, shown only while `loopbackTest` is on. Instead of a 24-bit burst it transmits a real frame (the first pin's slice, or 64 lights) back to back and bit-verifies the entire capture. This is what catches frame-rate corruption and RF interference on the data line — a 24-bit burst can pass through a wire that mangles a sustained frame. On failure the status names the first corrupted bit and light. + +## Cross-domain wiring + +The driver is added as a child of the `Drivers` container in `main.cpp` (under `if constexpr (platform::rmtTxChannels > 0)`), exactly like [NetworkSendDriver](NetworkSendDriver.md): it receives `setSourceBuffer` / `setCorrection` / `setLayer` from `Drivers::passBufferToDrivers`, 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 `boards.json` mechanism the LOLIN S3 uses). + +## Tests + +Full case list in the generated [unit tests § RmtLedDriver](../../../tests/unit-tests.md#rmtleddriver) (regenerated from the test files, never drifts). What's covered: + +- **Encoder (CI, host):** the bit→symbol contract — MSB-first, exact T0H/T1H tick widths, GRB ordering via Correction, RGBW → 32 symbols/light — with no hardware; written red before the encoder, pins it now. +- **Lifecycle (CI, host):** the symbol-buffer ownership — sized in `onBuildState`, survives a rebuild (reinit must not free it), freed on teardown — the class of bug that once reached hardware, now caught on every push. +- **Pins (CI, host):** the `pins`/`ledsPerPin` parsing (bad tokens, duplicates, chip limit) and slice arithmetic (explicit counts, even-split remainder, clamping) down to the per-pin symbol offsets, plus the empty-default idle (an unconfigured driver claims no GPIO). + +## Prior art + +The WS2812 protocol fundamentals and the RMT-first / loopback-test strategy come from the project's [LED driver analysis](../../../backlog/leddriver-analysis-top-down.md), which studies FastLED's `clockless_rmt_esp32`, hpwit's I2S drivers, and WLED — read for the lessons, not copied. FastLED's manual ping-pong refill (their "RMT5" worker, distinct from the IDF *driver* version above) is what makes their path more WiFi-resilient than a naive DMA-less refill ([FastLED #2082](https://github.com/FastLED/FastLED/issues/2082)); we sidestep that whole class of refill deadlines differently — by pre-encoding the entire frame and letting the modern driver stream it, so there is no per-frame refill to miss. Per-output (pin, count) rows are the WLED LED-settings pattern. + +## Source + +[RmtLedDriver.h](../../../../src/light/drivers/RmtLedDriver.h) diff --git a/docs/moonmodules/light/effects/AudioSpectrumEffect.md b/docs/moonmodules/light/effects/AudioSpectrumEffect.md new file mode 100644 index 00000000..7da392b6 --- /dev/null +++ b/docs/moonmodules/light/effects/AudioSpectrumEffect.md @@ -0,0 +1,26 @@ +# AudioSpectrumEffect + +The classic equalizer display: the microphone's **16 frequency bands** (bass → treble) spread across the grid's X axis, each column lighting from the bottom up in proportion to its band's magnitude — a bar graph that dances with the music. + +On a grid at least 3 rows tall, the **bottom row is an overall level/volume meter** (a horizontal VU bar lit left-to-right in proportion to `level`) and the spectrum bars sit in the rows above it. A shorter grid uses the full height for the spectrum. + +Reads the live frame from [AudioModule](../../core/AudioModule.md)`::latestFrame()`; no microphone or silence → all bands zero → dark. + +## Controls + +- `colorMode` — `height` (the default: each bar green at its base ramping to red at the top) or `per-band` (each column a distinct hue across the colour wheel, bass red → treble violet — the rainbow-analyser look). + +## Scaling to the grid + +The 16 bands map onto whatever the grid width is — column `x` shows band `x * 16 / width`: + +- a **16-wide** grid is one column per band, +- a **32-wide** grid gives each band two columns, +- an **8-wide** grid samples every other band, +- a **1-row strip** (height 1) collapses the bars to per-column brightness. + +So the analyser fills the surface at any size, including a 0×0 grid (it simply draws nothing). + +## Source + +[AudioSpectrumEffect.h](../../../../src/light/effects/AudioSpectrumEffect.h) diff --git a/docs/moonmodules/light/effects/AudioVolumeEffect.md b/docs/moonmodules/light/effects/AudioVolumeEffect.md new file mode 100644 index 00000000..7ba54c29 --- /dev/null +++ b/docs/moonmodules/light/effects/AudioVolumeEffect.md @@ -0,0 +1,17 @@ +# AudioVolumeEffect + +A VU meter on the whole grid: every light pulses with the microphone's sound **level**, its colour ramping from calm green (quiet) toward hot red (loud). The simplest audio-reactive effect: one scalar drives one brightness. + +Reads the live frame from [AudioModule](../../core/AudioModule.md)`::latestFrame()`; with no microphone (or in silence) the level is zero and the grid stays dark, so the effect is safe on any target and grid size. + +## Controls + +- **brightness**: overall ceiling (1-255). Default 255. + +## Notes + +The colour is a level-driven green→red ramp; modifiers and layouts give the flat VU surface its shape. Like every effect it writes only logical RGB; the driver's [Correction](../drivers/Correction.md) applies channel order and, for an RGBW preset, derives the white channel (`W = min(R, G, B)`) after brightness scaling. + +## Source + +[AudioVolumeEffect.h](../../../../src/light/effects/AudioVolumeEffect.h) diff --git a/docs/moonmodules/light/effects/GameOfLifeEffect.md b/docs/moonmodules/light/effects/GameOfLifeEffect.md index 08b1898c..6420a34c 100644 --- a/docs/moonmodules/light/effects/GameOfLifeEffect.md +++ b/docs/moonmodules/light/effects/GameOfLifeEffect.md @@ -1,5 +1,9 @@ # Game of Life Effect +![GameOfLifeEffect controls](../../../assets/screenshots/GameOfLifeEffect.png) + +![GameOfLifeEffect preview](../../../assets/screenshots/GameOfLifeEffect.gif) + Conway's Game of Life (B3/S23) on the XY plane. A D2 effect: it simulates the z=0 plane and `Layer::extrude` fills z on 3D layers. diff --git a/docs/moonmodules/light/effects/NetworkReceiveEffect.md b/docs/moonmodules/light/effects/NetworkReceiveEffect.md new file mode 100644 index 00000000..e180cefa --- /dev/null +++ b/docs/moonmodules/light/effects/NetworkReceiveEffect.md @@ -0,0 +1,55 @@ +# Network Receive Effect + +Receives lights-over-UDP data — **[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/), all at once** — and writes it into the layer buffer, behaving like any other effect: composable with modifiers, part of layer blending, selectable through the same UI. The receive side for industry senders (Resolume Arena, Madrix, xLights, LedFx, …) and the end-to-end pair with [NetworkSendDriver](../drivers/NetworkSendDriver.md). + +There is deliberately **no protocol control**: the effect binds the three well-known ports (6454 ArtNet, 5568 E1.31, 4048 DDP) simultaneously and validates each packet against its port's wire format — WLED's multi-port pattern. Whatever a sender speaks just works; the status field shows what is being received (`receiving DDP`, …). + +## Controls + +- `universe_start` (uint16_t, default 0) — first universe to accept (ArtNet/E1.31); a packet for universe `u` lands at byte offset `(u − universe_start) × channels_per_universe`. Universes below the start or beyond the buffer are ignored. E1.31 senders conventionally start at universe 1 — set both ends accordingly (see the sender's universe rule). +- `channels_per_universe` (uint16_t, default 510) — bytes each universe maps to. 510 = whole RGB lights per universe (the xLights/Falcon convention and our own sender's split); set **512** for senders that pack pixels across universe boundaries (Madrix-style). Also clamps each universe's payload to its slot, so a 512-channel frame from a 510-packed source can't bleed its 2 padding bytes into the next universe. + +DDP skips the universe math entirely: its packets carry a byte offset and land directly (clamped to the buffer). + +## ArtNet discovery (Resolume node lists) + +Controllers find output nodes by broadcasting **ArtPoll**; this effect answers with **ArtPollReply** (our IP, MAC, names, bound universe), so the device appears automatically in Resolume's Advanced Output, Madrix and xLights node lists instead of needing manual IP entry. The reply goes unicast to the poller via the platform's `sendToAddr`. + +## Rendering + +Opens and binds the three sockets in `setup()` (a taken port is reported in the status field; the other sockets still drain). `loop()` polls non-blocking at the frame boundary: it drains each socket (bounded per tick, so a packet flood can't wedge the render loop), validates each packet with its protocol's shared parser, and copies payloads into a **staging buffer**; staging is copied to the layer buffer every tick. + +The staging buffer is load-bearing: the Layer clears its buffer at the start of every tick, so writing packets straight into it would strobe black between frames. Staging gives hold-last-frame semantics. Sequence fields (and DDP's push flag) are ignored: out-of-order packets are last-write-wins. + +## Wire contracts + +The byte layouts live in [ArtNetPacket.h](../../../../src/light/ArtNetPacket.h), [E131Packet.h](../../../../src/light/E131Packet.h) and [DdpPacket.h](../../../../src/light/DdpPacket.h), shared with the sender. The receiver is liberal: any ArtNet protocol version, any E1.31 priority/sequence (no multi-source arbitration), any DDP data type. E1.31 **multicast is not joined** (unicast only — platform IGMP support is a backlog item); point sACN senders at the device's IP. + +## Tests + +[Unit tests: NetworkReceiveEffect](../../../tests/unit-tests.md#networkreceiveeffect) — per-protocol build→parse round-trips and reject cases, cross-protocol rejects, universe placement with `channels_per_universe` 510 and 512, DDP byte placement with hostile-offset clamping, ArtPoll/ArtPollReply layout, staging lifecycle, and a localhost round-trip driving all three protocol sockets at once. + +Live tier: `uv run scripts/scenario/run_network_live.py` ([MoonDeck.md § run_network_live](../../../../scripts/MoonDeck.md#run_network_live)) seeds real boards via all three protocols per round. + +## Design notes + +- Receive as an effect (not a separate input mechanism): any external light source is just another MoonModule that writes into a layer buffer. +- Processing is synchronous at the frame boundary — check for pending packets, never block ([architecture.md](../../../architecture.md) network-input rule). + +## Prior art + +### MoonLight — D_NetworkIn ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Drivers/D_NetworkIn.h)) + +ArtNet/E1.31/DDP receive in one driver node (protocol selected by control; we autodetect by port instead). + +### WLED — realtime UDP input + +Multi-port listening with per-packet header validation, plus ArtPollReply for controller discovery — the pattern this effect follows. + +### projectMM v1 — ArtNetInModule ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/effects/ArtNetInModule.h)) + +v1 treated ArtNet receive as an effect within a layer, the same architectural choice. + +## Source + +[NetworkReceiveEffect.h](../../../../src/light/effects/NetworkReceiveEffect.h) diff --git a/docs/moonmodules/light/effects/RingsEffect.md b/docs/moonmodules/light/effects/RingsEffect.md new file mode 100644 index 00000000..5ba6a2a2 --- /dev/null +++ b/docs/moonmodules/light/effects/RingsEffect.md @@ -0,0 +1,26 @@ +# Rings 2D Effect + +![RingsEffect controls](../../../assets/screenshots/RingsEffect.png) + +![RingsEffect preview](../../../assets/screenshots/RingsEffect.gif) + +Expanding concentric rings from random centre points. Each ring grows outward and respawns once it has expanded past the visible area. Multiple rings overlap with additive blending. + +(Formerly named "Ripples" — that name now belongs to the MoonLight sine-wave water-surface effect. This concentric-rings effect is "Rings".) + +## Controls + +- `count` (uint8_t, default 4, range 1-8) — number of simultaneously active rings +- `speed` (uint8_t, default 60, range 1-255) — expansion rate +- `thickness` (uint8_t, default 3, range 1-16) — ring thickness in pixels +- `hue_shift` (uint8_t, default 0, range 0-255) — global hue rotation + +An age-based fade makes old, wide rings disappear softly. Per-ring state (position + radius + hue) lives in a fixed array — no heap. + +## Tests + +[Unit tests: CheckerboardEffect](../../../tests/unit-tests.md#checkerboardeffect) — shared rendering/smoke coverage: non-zero output, spatial variation. (RingsEffect carries per-ring mutable state — position, radius, hue — with random respawn; that behaviour isn't unit-tested today.) + +## Source + +[RingsEffect.h](../../../../src/light/effects/RingsEffect.h) diff --git a/docs/moonmodules/light/effects/RipplesEffect.md b/docs/moonmodules/light/effects/RipplesEffect.md index 45a7857c..a276267a 100644 --- a/docs/moonmodules/light/effects/RipplesEffect.md +++ b/docs/moonmodules/light/effects/RipplesEffect.md @@ -1,23 +1,27 @@ -# Ripples 2D Effect +# Ripples 3D Effect ![RipplesEffect controls](../../../assets/screenshots/RipplesEffect.png) ![RipplesEffect preview](../../../assets/screenshots/RipplesEffect.gif) -Expanding concentric rings from random centre points. Each ripple grows outward and respawns once it has expanded past the visible area. Multiple ripples overlap with additive blending. +3D dancing sine-wave ripples — a reimplementation of MoonLight's Ripples. For each `(x, z)` column on the floor plane, the distance from the centre sets a wave phase, and one pixel per column is lit at the height `y = floor(h/2 · (1 + sin(dist / interval + time)))`. The lit surface ripples like water filling the volume, with the hue cycling over time and position. + +Genuinely 3D (`Dim::D3`): it writes a height across the y-axis. On a flat 2D layout (depth 1) it degenerates to a single rippling y-row, which is honest for a flat grid. ## Controls -- `count` (uint8_t, default 4, range 1-8) — number of simultaneously active ripples -- `speed` (uint8_t, default 60, range 1-255) — expansion rate -- `thickness` (uint8_t, default 3, range 1-16) — ring thickness in pixels -- `hue_shift` (uint8_t, default 0, range 0-255) — global hue rotation +- `speed` (uint8_t, default 50, range 0-99) — animation speed; 0 = frozen, 99 = fast +- `interval` (uint8_t, default 128, range 1-254) — wavefront spacing; low = tight rings, high = wide + +## Prior art + +Ported from [MoonLight](https://github.com/MoonModules/MoonLight)'s Ripples (via projectMM-v1), studied and rewritten against this project's `EffectBase` — we read the approach and implemented our own, reusing `core/color.h`'s `hsvToRgb` rather than MoonLight's inlined HSV. The wavefront math (distance → phase → sine height, the `1.3·(255−interval)/128·√h` spacing and `millis/(100−speed)/6.4` time base) follows MoonLight's so the look matches. -An age-based fade makes old, wide ripples disappear softly. Per-ripple state (position + radius + hue) lives in a fixed array — no heap. +Float trig (`sinf`/`sqrtf`) in the loop is consistent with the existing wave effects (Plasma, LavaLamp); the hot-path integer-math preference is for per-light colour work, not the handful of transcendental ops a wavefront needs. ## Tests -[Unit tests: CheckerboardEffect](../../../tests/unit-tests.md#checkerboardeffect) — shared rendering/smoke coverage: non-zero output, spatial variation. (RipplesEffect carries per-ripple mutable state — position, radius, hue — with random respawn; that behaviour isn't unit-tested today.) +[Unit tests: CheckerboardEffect](../../../tests/unit-tests.md#checkerboardeffect) — shared rendering/smoke coverage: non-zero output, spatial variation, plus a 0×0×0 grid robustness check. ## Source diff --git a/docs/moonmodules/light/modifiers/CheckerboardModifier.md b/docs/moonmodules/light/modifiers/CheckerboardModifier.md index 53b88cfd..90f17e52 100644 --- a/docs/moonmodules/light/modifiers/CheckerboardModifier.md +++ b/docs/moonmodules/light/modifiers/CheckerboardModifier.md @@ -1,5 +1,9 @@ # Checkerboard Modifier +![CheckerboardModifier controls](../../../assets/screenshots/CheckerboardModifier.png) + +![CheckerboardModifier preview](../../../assets/screenshots/CheckerboardModifier.gif) + Static modifier. Masks the layer in a checkerboard pattern: lights in the "off" squares are dropped (they receive nothing), lights in the "on" squares pass through unchanged. Unlike Multiply, this doesn't remap or resize — it's a spatial on/off mask applied to whatever the effect drew. ## Controls diff --git a/docs/moonmodules/light/modifiers/MultiplyModifier.md b/docs/moonmodules/light/modifiers/MultiplyModifier.md index df6c950a..b8efe1c4 100644 --- a/docs/moonmodules/light/modifiers/MultiplyModifier.md +++ b/docs/moonmodules/light/modifiers/MultiplyModifier.md @@ -1,5 +1,9 @@ # Multiply Modifier +![MultiplyModifier controls](../../../assets/screenshots/MultiplyModifier.png) + +![MultiplyModifier preview](../../../assets/screenshots/MultiplyModifier.gif) + Static modifier. Tiles the logical image across the physical box `multiply` times per axis, optionally reflecting alternate tiles. With a multiplier of 2 and mirror enabled on an axis, that axis folds in half — the classic kaleidoscope mirror. Multiply subsumes the old MirrorModifier: a pure mirror is `multiply = 2, mirror = true` on the chosen axes. ## Controls diff --git a/docs/performance.md b/docs/performance.md index a946cba6..aae534e3 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -67,11 +67,11 @@ Individual measurements vary ~5–10% on the Olimex board with no configuration | GlowParticles | 1706 | 586 | 128 | 14 | | Checkerboard | 8474 | 2617 | 397 | 21 | | Spiral | 2403 | 571 | 87 | 15 | -| Ripples | 1118 | 284 | 45 | 12 | +| Rings | 1118 | 284 | 45 | 12 | | LavaLamp | 3030 | 756 | 113 | 18 | | GameOfLife | 6802 | 1519 | 226 | 13 | -At 128² nearly every effect converges to ~12–23 FPS: the board is **ArtNet-output-bound** there (the ~38 ms synchronous send dominates the tick), so effect-compute differences wash out — the same physics the README narrates for the S3 over WiFi. Effect cost is visible at 64² and below, where Ripples / Noise / Spiral are the heaviest and Lines / Checkerboard the lightest. +At 128² nearly every effect converges to ~12–23 FPS: the board is **ArtNet-output-bound** there (the ~38 ms synchronous send dominates the tick), so effect-compute differences wash out — the same physics the README narrates for the S3 over WiFi. Effect cost is visible at 64² and below, where Rings / Noise / Spiral are the heaviest and Lines / Checkerboard the lightest. **Free internal heap** (KB) — the scarce resource on a no-PSRAM board; drops as the grid grows because the Layer buffer + LUT and the driver output buffer live in internal RAM: @@ -88,7 +88,7 @@ At 128² nearly every effect converges to ~12–23 FPS: the board is **ArtNet-ou | GlowParticles | 173 | 171 | 162 | 126 | | Checkerboard | 173 | 168 | 159 | 123 | | Spiral | 170 | 169 | 160 | 123 | -| Ripples | 170 | 168 | 160 | 124 | +| Rings | 170 | 168 | 160 | 124 | | LavaLamp | 170 | 169 | 160 | 124 | | GameOfLife | 171 | 165 | 150 | 90 | @@ -107,7 +107,7 @@ At 128² nearly every effect converges to ~12–23 FPS: the board is **ArtNet-ou | GlowParticles | 84 | 84 | 80 | 62 | | Checkerboard | 96 | 88 | 72 | 62 | | Spiral | 88 | 88 | 76 | 62 | -| Ripples | 92 | 88 | 80 | 62 | +| Rings | 92 | 88 | 80 | 62 | | LavaLamp | 92 | 88 | 72 | 62 | | GameOfLife | 88 | 84 | 68 | 46 | diff --git a/docs/testing.md b/docs/testing.md index 6a986fb0..f8674375 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -52,7 +52,7 @@ A test lives under the subfolder of its **primary** `@module`'s source domain (e ### Naming convention -- **Unit tests:** `unit_[_].cpp` — `` is the **CamelCase** class name as it appears in `// @module` (and in the source: `Layer`, `MoonModule`, `MultiplyModifier`, `ArtNetSendDriver`). The optional `` collapses when the file's the only test for its module (`unit_Color.cpp` is fine if `@module Color`); add it when one module has several test files (`unit_Layer_extrude.cpp`, `unit_Layer_zero_grid.cpp`, …) or when the topic genuinely clarifies what the file covers (`unit_FilesystemModule_persistence.cpp`). +- **Unit tests:** `unit_[_].cpp` — `` is the **CamelCase** class name as it appears in `// @module` (and in the source: `Layer`, `MoonModule`, `MultiplyModifier`, `NetworkSendDriver`). The optional `` collapses when the file's the only test for its module (`unit_Color.cpp` is fine if `@module Color`); add it when one module has several test files (`unit_Layer_extrude.cpp`, `unit_Layer_zero_grid.cpp`, …) or when the topic genuinely clarifies what the file covers (`unit_FilesystemModule_persistence.cpp`). - **Scenarios:** `scenario__.json` — same module-naming rule; the topic is always present because scenarios always cross multiple modules and the topic distinguishes the focus. - The **`"name"` field inside each scenario JSON** matches the filename stem exactly (e.g. `"name": "scenario_Layer_base_pipeline"`). The runner, the MoonDeck dropdown, the generated docs and `--name` on the CLI all use this single identifier. @@ -214,7 +214,7 @@ Every `scenario_*.json` carries top-level metadata plus a `description` per step "name": "scenario_GridLayout_grid_sizes", "module": "GridLayout", "mode": "mutate", - "also": ["Layer", "MultiplyModifier", "Drivers", "ArtNetSendDriver"], + "also": ["Layer", "MultiplyModifier", "Drivers", "NetworkSendDriver"], "description": "Walk the grid through 16x16 → 32x32 → 64x64 → 128x128 and assert a per-size FPS floor.", "fixture": [ { "name": "fix-layouts", "op": "add_module", "id": "Layouts", "type": "Layouts" }, @@ -223,7 +223,7 @@ Every `scenario_*.json` carries top-level metadata plus a `description` per step { "name": "fix-noise", "op": "add_module", "id": "Noise", "type": "NoiseEffect", "parent_id": "Layer" }, { "name": "fix-mirror", "op": "add_module", "id": "Multiply", "type": "MultiplyModifier", "parent_id": "Layer" }, { "name": "fix-drivers", "op": "add_module", "id": "Drivers", "type": "Drivers", "props": {"layer": "Layer"} }, - { "name": "fix-artnet", "op": "add_module", "id": "ArtNet", "type": "ArtNetSendDriver", "parent_id": "Drivers" } + { "name": "fix-artnet", "op": "add_module", "id": "ArtNet", "type": "NetworkSendDriver", "parent_id": "Drivers" } ], "reset": [ { "name": "reset-grid-width", "op": "set_control", "id": "Grid", "key": "width", "value": 128 }, @@ -356,6 +356,8 @@ Scenarios that add modules (e.g. `scenario_Layer_base_pipeline`, `scenario_Layer Memory tracking works on ESP32: `freeHeap` and `freeInternalHeap` report real values. Desktop returns 0 (unlimited). The control-change scenario verifies no memory leaks by checking that heap returns to baseline after a mirror toggle. +One live-tier test lives outside the scenario JSON schema because it spans **multiple devices**: `uv run scripts/scenario/run_network_live.py` runs a lights-over-UDP matrix (ArtNet, E1.31 and DDP) over every online board in moondeck.json — each board is once the sender, all others listen, and reception is asserted by reading each device's `/ws` preview stream (see [MoonDeck.md § run_network_live](../scripts/MoonDeck.md#run_network_live)). A device matrix needs loops and per-round state the declarative scenario JSON can't express, so it follows the `improv_smoke_test.py` script shape instead. + ## Hardware Verification All live scenarios pass on both desktop and ESP32 with `min_pct: 80` relative bounds. Per-module timing, memory allocation, and sizeof measurements for each platform are in [performance.md](performance.md). diff --git a/docs/tests/scenario-tests.md b/docs/tests/scenario-tests.md index bef20d53..71bac70a 100644 --- a/docs/tests/scenario-tests.md +++ b/docs/tests/scenario-tests.md @@ -4,13 +4,109 @@ Auto-generated from `test/scenarios/{core,light}/scenario_*.json` by `scripts/do Scenario tests are the integration tier in the [test strategy](../testing.md): each one is a JSON script that drives the full pipeline (PC or live ESP32) and captures tick / heap per step against per-target contracts. Run them with `scripts/scenario/run_scenario.py` (PC) or `scripts/scenario/run_live_scenario.py` (live device). See [testing.md § Performance contracts](../testing.md#performance-contracts-contracttarget) for the contract semantics. +## AudioModule + +### scenario_Audio_mutation + +`test/scenarios/light/scenario_Audio_mutation.json` — Add / configure / remove the AudioModule peripheral and an audio-reactive effect while the render pipeline runs, proving the robustness rule for the audio producer/consumer pair. AudioModule is a Peripheral (it sits beside the pipeline, publishing an AudioFrame), and the audio effects read it through the static AudioModule::latestFrame() accessor, NOT a boot-time pointer — so add/remove can happen in any order at runtime. The checks assert the pipeline keeps RENDERING (buffer non-null, fps measurable) through each mutation: adding the mic, setting its pins (the user-configures-then-runs flow), adding a consumer effect, and crucially REMOVING the mic while a consumer is still live (the consumer must fall back to a silent frame, never deref a dangling pointer — the bug the boot-loop fix and the unit lifecycle tests pin, here proven end-to-end through the Scheduler). On the host the mic is inert (hasI2sMic false), so this exercises the wiring/lifecycle, not real capture; capture is proven on hardware. Grid is 64x64 so the tick stays above the host microsecond clock at every step. + +**Mode**: `mutate` · **Also touches**: SystemModule, Layouts, GridLayout, Layer, RainbowEffect, AudioVolumeEffect, AudioSpectrumEffect, Drivers, PreviewDriver + +#### `measure-pipeline-only` (measure) 📏 + +Baseline: the render pipeline runs with no audio module present. + +**Bounds**: +- FPS ≥ 1 (absolute) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | — / 100,000-111,111 | — / unlimited | — / unlimited | + +- `pc-macos`: observed 2026-06-12 + +#### `measure-audio-added` (measure) 📏 + +Pipeline still renders with the (idle, unconfigured) mic added. + +**Setup** (preceding non-measured steps): +- `add-audio-module` (add_module) — Add the AudioModule peripheral under SystemModule (where the user adds it, beside the board). Pins default unset, so it stays idle; the pipeline must keep rendering. + +**Bounds**: +- FPS ≥ 1 (absolute) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | — / 111,111 | — / unlimited | — / unlimited | + +- `pc-macos`: observed 2026-06-12 + +#### `measure-consumer-live` (measure) 📏 + +Pipeline renders with the producer + consumer both wired. + +**Setup** (preceding non-measured steps): +- `configure-audio-pins` (set_control) — Set a mic pin — the user-configures-then-runs flow. On host the mic is inert, but the buildState rebuild must not disturb the running pipeline. +- `add-audio-consumer` (add_module) — Add an AudioVolumeEffect consumer under the Layer. It reads the mic via the static accessor; with the mic present it gets the live (silent, on host) frame. + +**Bounds**: +- FPS ≥ 1 (absolute) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | — / 100,000-111,111 | — / unlimited | — / unlimited | + +- `pc-macos`: observed 2026-06-12 + +#### `measure-after-mic-removed` (measure) 📏 + +Mic gone, consumer remains: pipeline keeps rendering on silent audio (buffer non-null, fps measurable). No crash from the orphaned consumer. + +**Setup** (preceding non-measured steps): +- `remove-audio-module` (remove_module) — Remove the mic while the consumer is STILL live. The consumer must fall back to AudioModule::latestFrame()'s static silence — no dangling pointer, no crash. This is the robustness rule's hardest case for this pair. + +**Bounds**: +- FPS ≥ 1 (absolute) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | — / 111,111 | — / unlimited | — / unlimited | + +- `pc-macos`: observed 2026-06-12 + +#### `measure-back-to-baseline` (measure) 📏 + +Both audio modules gone: back to the pipeline-only baseline, still rendering. + +**Setup** (preceding non-measured steps): +- `remove-audio-consumer` (remove_module) — Remove the orphaned consumer too — clean teardown, pipeline still live. + +**Bounds**: +- FPS ≥ 1 (absolute) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | — / 100,000-111,111 | — / unlimited | — / unlimited | + +- `pc-macos`: observed 2026-06-12 + ## GridLayout ### scenario_GridLayout_grid_sizes `test/scenarios/light/scenario_GridLayout_grid_sizes.json` — Walk the grid through 16x16 → 32x32 → 64x64 → 128x128 and assert a per-size FPS floor. -**Mode**: `mutate` · **Also touches**: Layer, MultiplyModifier, NoiseEffect, Drivers, ArtNetSendDriver, PreviewDriver +**Mode**: `mutate` · **Also touches**: Layer, MultiplyModifier, NoiseEffect, Drivers, NetworkSendDriver, PreviewDriver #### `size-16x16` (set_control) 📏 @@ -58,14 +154,14 @@ Scenario tests are the integration tier in the [test strategy](../testing.md): e | `esp32-eth` | ≥ 303 / 379-381 | ≥ 161KB / 172KB | ≥ 78KB / 92KB | | `esp32-eth-wifi` | ≥ 400 / 390 | ≥ 142KB / 132KB | ≥ 49KB / 50KB | | `esp32s3-n16r8` | — / 288 | — / 8349KB | — / 140KB | -| `pc-macos` | ≥ 100,000 / 111,111-200,000 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 100,000 / 76,923-200,000 | unlimited / unlimited | — / unlimited | | `pc-windows` | — / 71,429-90,909 | — / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: contract set 2026-06-02 "anti-regression floor; LUT-fit telemetry baseline" · observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 - `esp32s3-n16r8`: observed 2026-06-04 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-06 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-11 - `pc-windows`: observed 2026-06-07 #### `size-64x64` (set_control) 📏 @@ -86,14 +182,14 @@ Scenario tests are the integration tier in the [test strategy](../testing.md): e | `esp32-eth` | ≥ 55.6 / 74.5-74.7 | ≥ 137KB / 147KB | ≥ 54KB / 62KB | | `esp32-eth-wifi` | ≥ 76.9 / 85.7 | ≥ 117KB / 108KB | ≥ 44KB / 48KB | | `esp32s3-n16r8` | — / 25.9 | — / 8310KB | — / 152KB | -| `pc-macos` | ≥ 33,333 / 30,303-43,478 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 33,333 / 4,484-43,478 | unlimited / unlimited | — / unlimited | | `pc-windows` | — / 17,857-22,727 | — / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: contract set 2026-06-02 "anti-regression floor; LUT-fit telemetry baseline" · observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 - `esp32s3-n16r8`: observed 2026-06-04 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-11 - `pc-windows`: observed 2026-06-07 #### `size-128x128` (set_control) 📏 @@ -114,14 +210,14 @@ Scenario tests are the integration tier in the [test strategy](../testing.md): e | `esp32-eth` | ≥ 9.1 / 10.5-10.6 | ≥ 122KB / 132KB | ≥ 47KB / 48KB | | `esp32-eth-wifi` | ≥ 10.0 / 54.5 | ≥ 103KB / 129KB | ≥ 44KB / 52KB | | `esp32s3-n16r8` | — / 6.1 | — / 8163KB | — / 164KB | -| `pc-macos` | ≥ 8,333 / 4,975-10,204 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 8,333 / 4,902-10,204 | unlimited / unlimited | — / unlimited | | `pc-windows` | — / 3,676-4,505 | — / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: contract set 2026-06-02 "anti-regression floor; LUT-fit telemetry baseline" · observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 - `esp32s3-n16r8`: observed 2026-06-04 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-11 - `pc-windows`: observed 2026-06-07 ### scenario_GridLayout_resize @@ -164,13 +260,13 @@ Shrink to 128x64. Measured: FPS must stay within 20% of the baseline (proves the | `esp32` | — / 11.1 | — / 63KB | — / 17KB | | `esp32-eth` | — / 26.4-26.5 | — / 114KB | — / 48KB | | `esp32-eth-wifi` | ≥ 22.2 / 31.8 | ≥ 83KB / 75KB | — / 24KB | -| `pc-macos` | ≥ 16,667 / 5,208-21,739 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 16,667 / 4,695-21,739 | unlimited / unlimited | — / unlimited | | `pc-windows` | — / 7,299-10,638 | — / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-08 - `pc-windows`: observed 2026-06-07 #### `grow-to-128x128` (set_control) 📏 @@ -197,9 +293,9 @@ Grow back to 128x128. Measured: confirms the heap can return to the heavy baseli ### scenario_AllEffects_grid_sizes -`test/scenarios/light/scenario_AllEffects_grid_sizes.json` — Sweep every effect (no modifier) across 16/32/64/128 square grids and measure tick/FPS, free internal heap, max internal block per (effect, size). The scenario prepares its own canvas: clear_children wipes whatever layouts/layers/drivers the device had, then it rebuilds exactly one Layout(Grid) + one Layer + one effect (no modifier) + ArtNet, so the measurement is each effect's raw cost over the full grid through the real output driver, on any starting device state. PreviewDriver is apparatus (non-deletable) so it survives the clear. Effects are swapped via replace_module at a fixed Layer child slot; grid resized via set_control (width then height, measuring after height so we never measure an N x 128 stripe). +`test/scenarios/light/scenario_AllEffects_grid_sizes.json` — Sweep the effect set (no modifier) across 16/32/64/128 square grids and measure tick/FPS, free internal heap, max internal block per (effect, size). Covers 14 effects: Lines, Rainbow, Noise, Plasma, PlasmaPalette, Metaballs, Fire, Particles, GlowParticles, Checkerboard, Spiral, Rings, LavaLamp, GameOfLife. NOT yet covered: RipplesEffect (added later; its measurement block is a pending addition — see backlog). The scenario prepares its own canvas: clear_children wipes whatever layouts/layers/drivers the device had, then it rebuilds exactly one Layout(Grid) + one Layer + one effect (no modifier) + NetworkSendDriver, so the measurement is each effect's raw cost over the full grid through the real output driver, on any starting device state. PreviewDriver is apparatus (non-deletable) so it survives the clear. Effects are swapped via replace_module at a fixed Layer child slot; grid resized via set_control (width then height, measuring after height so we never measure an N x 128 stripe). -**Mode**: `mutate` · **Also touches**: Layouts, GridLayout, Drivers, ArtNetSendDriver, PreviewDriver, LinesEffect, RainbowEffect, NoiseEffect, PlasmaEffect, PlasmaPaletteEffect, MetaballsEffect, FireEffect, ParticlesEffect, GlowParticlesEffect, CheckerboardEffect, SpiralEffect, RipplesEffect, LavaLampEffect, GameOfLifeEffect +**Mode**: `mutate` · **Also touches**: Layouts, GridLayout, Drivers, NetworkSendDriver, PreviewDriver, LinesEffect, RainbowEffect, NoiseEffect, PlasmaEffect, PlasmaPaletteEffect, MetaballsEffect, FireEffect, ParticlesEffect, GlowParticlesEffect, CheckerboardEffect, SpiralEffect, RingsEffect, LavaLampEffect, GameOfLifeEffect #### `LinesEffect-16x16` (set_control) 📏 @@ -224,11 +320,11 @@ LinesEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 12,658 | — / 221KB | — / 108KB | +| `esp32-eth` | — / 12,658-14,925 | — / 221KB | — / 108KB | | `pc-macos` | — / — | — / unlimited | — / unlimited | | `pc-windows` | — / — | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -243,11 +339,11 @@ LinesEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 7,634 | — / 215KB | — / 108KB | +| `esp32-eth` | — / 7,634-10,000 | — / 215KB-216KB | — / 108KB | | `pc-macos` | — / — | — / unlimited | — / unlimited | | `pc-windows` | — / — | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -262,11 +358,11 @@ LinesEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 2,304 | — / 195KB | — / 108KB | +| `esp32-eth` | — / 2,304-2,755 | — / 195KB-198KB | — / 108KB | | `pc-macos` | — / 1,000,000-— | — / unlimited | — / unlimited | | `pc-windows` | — / 1,000,000 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -281,11 +377,11 @@ LinesEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 23.3 | — / 126KB | — / 62KB | +| `esp32-eth` | — / 23.3-26.8 | — / 126KB | — / 62KB | | `pc-macos` | — / 1,000,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 37,037-250,000 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -303,12 +399,12 @@ RainbowEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, ma | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 3,831 | — / 173KB | — / 92KB | -| `pc-macos` | — / — | — / unlimited | — / unlimited | +| `esp32-eth` | — / 3,831-3,937 | — / 173KB | — / 92KB-108KB | +| `pc-macos` | — / 1,000,000-— | — / unlimited | — / unlimited | | `pc-windows` | — / 250,000-500,000 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-10 - `pc-windows`: observed 2026-06-07 #### `RainbowEffect-32x32` (set_control) 📏 @@ -322,11 +418,11 @@ RainbowEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, m | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 968 | — / 168KB | — / 88KB | +| `esp32-eth` | — / 968-1,020 | — / 168KB-171KB | — / 88KB-108KB | | `pc-macos` | — / 500,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 90,909-166,667 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -341,12 +437,12 @@ RainbowEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, m | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 143 | — / 159KB | — / 76KB | -| `pc-macos` | — / 111,111-125,000 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 143-176 | — / 159KB-162KB | — / 76KB-108KB | +| `pc-macos` | — / 71,429-125,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 34,483-40,000 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-12 - `pc-windows`: observed 2026-06-07 #### `RainbowEffect-128x128` (set_control) 📏 @@ -360,12 +456,12 @@ RainbowEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 22.6 | — / 126KB | — / 62KB | -| `pc-macos` | — / 25,641-28,571 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 20.2-22.6 | — / 126KB | — / 62KB-108KB | +| `pc-macos` | — / 24,390-28,571 | — / unlimited | — / unlimited | | `pc-windows` | — / 6,098-8,929 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-12 - `pc-windows`: observed 2026-06-07 #### `NoiseEffect-16x16` (set_control) 📏 @@ -382,11 +478,11 @@ NoiseEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 1,117 | — / 172KB | — / 92KB | +| `esp32-eth` | — / 1,117-1,172 | — / 172KB-173KB | — / 92KB-108KB | | `pc-macos` | — / 250,000-333,333 | — / unlimited | — / unlimited | | `pc-windows` | — / 83,333-111,111 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -401,12 +497,12 @@ NoiseEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 324 | — / 168KB | — / 88KB | -| `pc-macos` | — / 62,500-71,429 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 276-324 | — / 168KB-171KB | — / 88KB-108KB | +| `pc-macos` | — / 58,824-71,429 | — / unlimited | — / unlimited | | `pc-windows` | — / 25,000-29,412 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-10 - `pc-windows`: observed 2026-06-07 #### `NoiseEffect-64x64` (set_control) 📏 @@ -420,12 +516,12 @@ NoiseEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 71.8 | — / 159KB | — / 76KB | -| `pc-macos` | — / 13,514-15,625 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 45.3-71.8 | — / 159KB-162KB | — / 76KB-108KB | +| `pc-macos` | — / 12,500-15,625 | — / unlimited | — / unlimited | | `pc-windows` | — / 4,739-6,757 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-11 - `pc-windows`: observed 2026-06-07 #### `NoiseEffect-128x128` (set_control) 📏 @@ -439,12 +535,12 @@ NoiseEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 17.1 | — / 126KB | — / 62KB | -| `pc-macos` | — / 2,924-3,268 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 12.3-17.1 | — / 126KB | — / 62KB-108KB | +| `pc-macos` | — / 1,357-3,268 | — / unlimited | — / unlimited | | `pc-windows` | — / 1,190-1,437 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-12 - `pc-windows`: observed 2026-06-07 #### `PlasmaEffect-16x16` (set_control) 📏 @@ -461,11 +557,11 @@ PlasmaEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 3,195 | — / 174KB | — / 92KB | +| `esp32-eth` | — / 3,021-3,195 | — / 173KB-174KB | — / 92KB-108KB | | `pc-macos` | — / 1,000,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 500,000 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -480,12 +576,12 @@ PlasmaEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, ma | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 830 | — / 171KB | — / 92KB | -| `pc-macos` | — / 333,333 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 830-863 | — / 171KB | — / 92KB-108KB | +| `pc-macos` | — / 200,000-333,333 | — / unlimited | — / unlimited | | `pc-windows` | — / 142,857-166,667 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-12 - `pc-windows`: observed 2026-06-07 #### `PlasmaEffect-64x64` (set_control) 📏 @@ -499,12 +595,12 @@ PlasmaEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, ma | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 135 | — / 162KB | — / 84KB | -| `pc-macos` | — / 66,667-90,909 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 135-181 | — / 162KB | — / 84KB-108KB | +| `pc-macos` | — / 62,500-90,909 | — / unlimited | — / unlimited | | `pc-windows` | — / 35,714-43,478 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 → 2026-06-08 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-12 - `pc-windows`: observed 2026-06-07 #### `PlasmaEffect-128x128` (set_control) 📏 @@ -518,12 +614,12 @@ PlasmaEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 18.3 | — / 126KB | — / 62KB | -| `pc-macos` | — / 19,231-22,727 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 18.3-24.3 | — / 126KB | — / 62KB-108KB | +| `pc-macos` | — / 17,241-22,727 | — / unlimited | — / unlimited | | `pc-windows` | — / 7,874-9,709 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-10 - `pc-windows`: observed 2026-06-07 #### `PlasmaPaletteEffect-16x16` (set_control) 📏 @@ -540,11 +636,11 @@ PlasmaPaletteEffect at 16x16 (256 lights) — measure tick/FPS, free internal he | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 6,024 | — / 170KB | — / 92KB | +| `esp32-eth` | — / 6,024-6,369 | — / 170KB-173KB | — / 92KB-108KB | | `pc-macos` | — / — | — / unlimited | — / unlimited | | `pc-windows` | — / 500,000-1,000,000 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -559,12 +655,12 @@ PlasmaPaletteEffect at 32x32 (1024 lights) — measure tick/FPS, free internal h | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 1,733 | — / 168KB | — / 88KB | -| `pc-macos` | — / 1,000,000 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 1,733-2,237 | — / 168KB-171KB | — / 88KB-108KB | +| `pc-macos` | — / 500,000-1,000,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 250,000-333,333 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-10 - `pc-windows`: observed 2026-06-07 #### `PlasmaPaletteEffect-64x64` (set_control) 📏 @@ -578,11 +674,11 @@ PlasmaPaletteEffect at 64x64 (4096 lights) — measure tick/FPS, free internal h | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 268 | — / 161KB | — / 80KB | +| `esp32-eth` | — / 268-481 | — / 161KB-162KB | — / 80KB-108KB | | `pc-macos` | — / 142,857-200,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 50,000-71,429 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -597,12 +693,12 @@ PlasmaPaletteEffect at 128x128 (16384 lights) — measure tick/FPS, free interna | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 21.8 | — / 126KB | — / 62KB | -| `pc-macos` | — / 41,667-50,000 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 21.8-32.1 | — / 126KB | — / 62KB-108KB | +| `pc-macos` | — / 32,258-50,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 12,346-18,868 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-12 - `pc-windows`: observed 2026-06-07 #### `MetaballsEffect-16x16` (set_control) 📏 @@ -619,12 +715,12 @@ MetaballsEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 2,016 | — / 174KB | — / 92KB | -| `pc-macos` | — / 1,000,000 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 2,016-2,062 | — / 173KB-174KB | — / 92KB-108KB | +| `pc-macos` | — / 500,000-1,000,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 200,000-250,000 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-12 - `pc-windows`: observed 2026-06-07 #### `MetaballsEffect-32x32` (set_control) 📏 @@ -638,11 +734,11 @@ MetaballsEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 522 | — / 171KB | — / 92KB | +| `esp32-eth` | — / 522-727 | — / 171KB | — / 92KB-108KB | | `pc-macos` | — / 200,000-250,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 50,000-62,500 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -657,12 +753,12 @@ MetaballsEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 103 | — / 162KB | — / 84KB | -| `pc-macos` | — / 55,556-62,500 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 89.3-103 | — / 162KB | — / 84KB-108KB | +| `pc-macos` | — / 38,462-62,500 | — / unlimited | — / unlimited | | `pc-windows` | — / 12,500-15,385 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-12 - `pc-windows`: observed 2026-06-07 #### `MetaballsEffect-128x128` (set_control) 📏 @@ -676,12 +772,12 @@ MetaballsEffect at 128x128 (16384 lights) — measure tick/FPS, free internal he | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 18.7 | — / 126KB | — / 62KB | -| `pc-macos` | — / 13,158-15,873 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 16.3-18.7 | — / 126KB | — / 62KB-108KB | +| `pc-macos` | — / 5,263-15,873 | — / unlimited | — / unlimited | | `pc-windows` | — / 2,786-3,636 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-12 - `pc-windows`: observed 2026-06-07 #### `FireEffect-16x16` (set_control) 📏 @@ -698,11 +794,11 @@ FireEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max i | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 2,762 | — / 173KB | — / 96KB | +| `esp32-eth` | — / 2,710-2,762 | — / 173KB | — / 96KB-108KB | | `pc-macos` | — / 1,000,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 333,333-500,000 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -717,12 +813,12 @@ FireEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 784 | — / 170KB | — / 92KB | -| `pc-macos` | — / 250,000-333,333 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 678-784 | — / 170KB | — / 92KB-108KB | +| `pc-macos` | — / 142,857-333,333 | — / unlimited | — / unlimited | | `pc-windows` | — / 100,000-125,000 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-12 - `pc-windows`: observed 2026-06-07 #### `FireEffect-64x64` (set_control) 📏 @@ -736,12 +832,12 @@ FireEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 160 | — / 158KB | — / 76KB | -| `pc-macos` | — / 55,556-76,923 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 122-160 | — / 158KB | — / 76KB-108KB | +| `pc-macos` | — / 27,778-76,923 | — / unlimited | — / unlimited | | `pc-windows` | — / 27,027-33,333 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-12 - `pc-windows`: observed 2026-06-07 #### `FireEffect-128x128` (set_control) 📏 @@ -755,12 +851,12 @@ FireEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, m | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 21.5 | — / 110KB | — / 62KB | -| `pc-macos` | — / 16,949-19,231 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 18.1-21.5 | — / 110KB | — / 62KB | +| `pc-macos` | — / 6,803-19,231 | — / unlimited | — / unlimited | | `pc-windows` | — / 6,452-7,194 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-12 - `pc-windows`: observed 2026-06-07 #### `ParticlesEffect-16x16` (set_control) 📏 @@ -777,11 +873,11 @@ ParticlesEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 4,717 | — / 172KB | — / 80KB | +| `esp32-eth` | — / 4,566-4,717 | — / 172KB | — / 80KB-108KB | | `pc-macos` | — / 1,000,000-— | — / unlimited | — / unlimited | | `pc-windows` | — / 500,000 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -796,11 +892,11 @@ ParticlesEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 1,848 | — / 168KB | — / 80KB | +| `esp32-eth` | — / 1,481-1,848 | — / 168KB | — / 80KB-108KB | | `pc-macos` | — / 333,333-500,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 166,667-250,000 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -815,11 +911,11 @@ ParticlesEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 425 | — / 150KB | — / 68KB | +| `esp32-eth` | — / 262-425 | — / 150KB | — / 68KB-108KB | | `pc-macos` | — / 111,111-142,857 | — / unlimited | — / unlimited | | `pc-windows` | — / 52,632-71,429 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -834,12 +930,12 @@ ParticlesEffect at 128x128 (16384 lights) — measure tick/FPS, free internal he | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 30.8 | — / 78KB | — / 34KB | -| `pc-macos` | — / 27,027-34,483 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 22.8-30.8 | — / 78KB | — / 34KB-62KB | +| `pc-macos` | — / 27,027-35,714 | — / unlimited | — / unlimited | | `pc-windows` | — / 12,987-15,873 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-08 - `pc-windows`: observed 2026-06-07 #### `GlowParticlesEffect-16x16` (set_control) 📏 @@ -856,11 +952,11 @@ GlowParticlesEffect at 16x16 (256 lights) — measure tick/FPS, free internal he | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 1,706 | — / 174KB | — / 84KB | +| `esp32-eth` | — / 1,689-1,706 | — / 173KB-174KB | — / 84KB-108KB | | `pc-macos` | — / 500,000-1,000,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 142,857-166,667 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -875,11 +971,11 @@ GlowParticlesEffect at 32x32 (1024 lights) — measure tick/FPS, free internal h | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 586 | — / 171KB | — / 84KB | +| `esp32-eth` | — / 411-586 | — / 171KB | — / 84KB-108KB | | `pc-macos` | — / 52,632-250,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 35,714-45,455 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -894,11 +990,11 @@ GlowParticlesEffect at 64x64 (4096 lights) — measure tick/FPS, free internal h | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 128 | — / 162KB | — / 80KB | +| `esp32-eth` | — / 77.9-128 | — / 162KB | — / 80KB-108KB | | `pc-macos` | — / 37,037-55,556 | — / unlimited | — / unlimited | | `pc-windows` | — / 8,850-10,638 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -913,12 +1009,12 @@ GlowParticlesEffect at 128x128 (16384 lights) — measure tick/FPS, free interna | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 14.3 | — / 126KB | — / 62KB | -| `pc-macos` | — / 7,752-14,286 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 14.3-15.0 | — / 126KB | — / 62KB-108KB | +| `pc-macos` | — / 7,752-14,493 | — / unlimited | — / unlimited | | `pc-windows` | — / 1,949-2,370 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-10 - `pc-windows`: observed 2026-06-07 #### `CheckerboardEffect-16x16` (set_control) 📏 @@ -935,11 +1031,11 @@ CheckerboardEffect at 16x16 (256 lights) — measure tick/FPS, free internal hea | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 8,475 | — / 173KB | — / 96KB | +| `esp32-eth` | — / 8,475-9,009 | — / 173KB | — / 96KB-108KB | | `pc-macos` | — / — | — / unlimited | — / unlimited | | `pc-windows` | — / 500,000 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -954,11 +1050,11 @@ CheckerboardEffect at 32x32 (1024 lights) — measure tick/FPS, free internal he | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 2,618 | — / 168KB | — / 88KB | +| `esp32-eth` | — / 2,618-2,809 | — / 168KB-171KB | — / 88KB-108KB | | `pc-macos` | — / 1,000,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 142,857-166,667 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -973,12 +1069,12 @@ CheckerboardEffect at 64x64 (4096 lights) — measure tick/FPS, free internal he | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 397 | — / 159KB | — / 72KB | -| `pc-macos` | — / 250,000 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 397-563 | — / 159KB-162KB | — / 72KB-108KB | +| `pc-macos` | — / 200,000-250,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 34,483-45,455 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-08 - `pc-windows`: observed 2026-06-07 #### `CheckerboardEffect-128x128` (set_control) 📏 @@ -992,11 +1088,11 @@ CheckerboardEffect at 128x128 (16384 lights) — measure tick/FPS, free internal | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 21.2 | — / 123KB | — / 62KB | +| `esp32-eth` | — / 21.2-31.5 | — / 123KB-126KB | — / 62KB-108KB | | `pc-macos` | — / 45,455-62,500 | — / unlimited | — / unlimited | | `pc-windows` | — / 8,475-10,638 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -1014,11 +1110,11 @@ SpiralEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 2,404 | — / 170KB | — / 88KB | +| `esp32-eth` | — / 2,404-2,427 | — / 170KB-173KB | — / 88KB-108KB | | `pc-macos` | — / 1,000,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 250,000-500,000 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -1033,12 +1129,12 @@ SpiralEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, ma | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 572 | — / 170KB | — / 88KB | -| `pc-macos` | — / 250,000 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 572-691 | — / 170KB-171KB | — / 88KB-108KB | +| `pc-macos` | — / 166,667-250,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 100,000-125,000 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-11 - `pc-windows`: observed 2026-06-07 #### `SpiralEffect-64x64` (set_control) 📏 @@ -1052,11 +1148,11 @@ SpiralEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, ma | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 87.0 | — / 161KB | — / 76KB | +| `esp32-eth` | — / 87.0-140 | — / 161KB-162KB | — / 76KB-108KB | | `pc-macos` | — / 22,222-62,500 | — / unlimited | — / unlimited | | `pc-windows` | — / 23,810-27,027 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -1071,91 +1167,91 @@ SpiralEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 15.5 | — / 123KB | — / 62KB | +| `esp32-eth` | — / 15.5-22.7 | — / 123KB-126KB | — / 62KB-108KB | | `pc-macos` | — / 9,901-13,889 | — / unlimited | — / unlimited | | `pc-windows` | — / 5,102-6,579 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 -#### `RipplesEffect-16x16` (set_control) 📏 +#### `RingsEffect-16x16` (set_control) 📏 -RipplesEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block. +RingsEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block. **Setup** (preceding non-measured steps): -- `RipplesEffect-pre-w` (set_control) -- `RipplesEffect-pre-h` (set_control) -- `fx-RipplesEffect` (replace_module) -- `RipplesEffect-16x16-w` (set_control) +- `RingsEffect-pre-w` (set_control) +- `RingsEffect-pre-h` (set_control) +- `fx-RingsEffect` (replace_module) +- `RingsEffect-16x16-w` (set_control) **Performance** (contract / observed) — tick stored, FPS shown: | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 1,119 | — / 170KB | — / 92KB | -| `pc-macos` | — / 333,333 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 1,119-1,129 | — / 170KB-173KB | — / 92KB-108KB | +| `pc-macos` | — / 250,000-1,000,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 100,000-125,000 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-11 - `pc-windows`: observed 2026-06-07 -#### `RipplesEffect-32x32` (set_control) 📏 +#### `RingsEffect-32x32` (set_control) 📏 -RipplesEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block. +RingsEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block. **Setup** (preceding non-measured steps): -- `RipplesEffect-32x32-w` (set_control) +- `RingsEffect-32x32-w` (set_control) **Performance** (contract / observed) — tick stored, FPS shown: | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 284 | — / 168KB | — / 88KB | -| `pc-macos` | — / 83,333-125,000 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 284-413 | — / 168KB-171KB | — / 88KB-108KB | +| `pc-macos` | — / 83,333-250,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 38,462-47,619 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-10 - `pc-windows`: observed 2026-06-07 -#### `RipplesEffect-64x64` (set_control) 📏 +#### `RingsEffect-64x64` (set_control) 📏 -RipplesEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block. +RingsEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block. **Setup** (preceding non-measured steps): -- `RipplesEffect-64x64-w` (set_control) +- `RingsEffect-64x64-w` (set_control) **Performance** (contract / observed) — tick stored, FPS shown: | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 45.0 | — / 161KB | — / 80KB | -| `pc-macos` | — / 30,303-35,714 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 45.0-96.6 | — / 161KB-162KB | — / 80KB-108KB | +| `pc-macos` | — / 30,303-58,824 | — / unlimited | — / unlimited | | `pc-windows` | — / 12,048-15,152 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 → 2026-06-08 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-10 - `pc-windows`: observed 2026-06-07 -#### `RipplesEffect-128x128` (set_control) 📏 +#### `RingsEffect-128x128` (set_control) 📏 -RipplesEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block. +RingsEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block. **Setup** (preceding non-measured steps): -- `RipplesEffect-128x128-w` (set_control) +- `RingsEffect-128x128-w` (set_control) **Performance** (contract / observed) — tick stored, FPS shown: | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 12.2 | — / 125KB | — / 62KB | -| `pc-macos` | — / 8,403-9,259 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 12.2-13.0 | — / 125KB-126KB | — / 62KB-108KB | +| `pc-macos` | — / 7,937-13,889 | — / unlimited | — / unlimited | | `pc-windows` | — / 3,067-3,831 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-12 - `pc-windows`: observed 2026-06-07 #### `LavaLampEffect-16x16` (set_control) 📏 @@ -1172,11 +1268,11 @@ LavaLampEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, m | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 3,030 | — / 170KB | — / 92KB | +| `esp32-eth` | — / 3,030-3,106 | — / 170KB-173KB | — / 92KB-108KB | | `pc-macos` | — / 1,000,000-— | — / unlimited | — / unlimited | | `pc-windows` | — / 250,000-500,000 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -1191,11 +1287,11 @@ LavaLampEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 756 | — / 170KB | — / 88KB | +| `esp32-eth` | — / 756-801 | — / 170KB-171KB | — / 88KB-108KB | | `pc-macos` | — / 333,333-500,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 66,667-111,111 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -1210,12 +1306,12 @@ LavaLampEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 113 | — / 161KB | — / 72KB | -| `pc-macos` | — / 100,000-125,000 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 113-136 | — / 161KB-162KB | — / 72KB-108KB | +| `pc-macos` | — / 100,000-142,857 | — / unlimited | — / unlimited | | `pc-windows` | — / 23,810-29,412 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-08 - `pc-windows`: observed 2026-06-07 #### `LavaLampEffect-128x128` (set_control) 📏 @@ -1229,12 +1325,12 @@ LavaLampEffect at 128x128 (16384 lights) — measure tick/FPS, free internal hea | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 18.2 | — / 125KB | — / 62KB | -| `pc-macos` | — / 28,571-32,258 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 18.2-19.0 | — / 125KB-126KB | — / 62KB-108KB | +| `pc-macos` | — / 22,222-33,333 | — / unlimited | — / unlimited | | `pc-windows` | — / 4,926-6,757 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-11 - `pc-windows`: observed 2026-06-07 #### `GameOfLifeEffect-16x16` (set_control) 📏 @@ -1251,11 +1347,11 @@ GameOfLifeEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 6,803 | — / 171KB | — / 88KB | +| `esp32-eth` | — / 6,803-7,092 | — / 171KB-173KB | — / 88KB-108KB | | `pc-macos` | — / — | — / unlimited | — / unlimited | | `pc-windows` | — / 500,000-1,000,000 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 - `pc-macos`: observed 2026-06-07 - `pc-windows`: observed 2026-06-07 @@ -1270,12 +1366,12 @@ GameOfLifeEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 1,520 | — / 166KB | — / 84KB | -| `pc-macos` | — / 1,000,000 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 1,520-1,653 | — / 166KB-169KB | — / 84KB-108KB | +| `pc-macos` | — / 333,333-1,000,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 166,667-200,000 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-11 - `pc-windows`: observed 2026-06-07 #### `GameOfLifeEffect-64x64` (set_control) 📏 @@ -1289,12 +1385,12 @@ GameOfLifeEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 227 | — / 151KB | — / 68KB | -| `pc-macos` | — / 200,000 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 227-235 | — / 151KB-154KB | — / 68KB-108KB | +| `pc-macos` | — / 142,857-200,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 38,462-47,619 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-11 - `pc-windows`: observed 2026-06-07 #### `GameOfLifeEffect-128x128` (set_control) 📏 @@ -1308,23 +1404,23 @@ GameOfLifeEffect at 128x128 (16384 lights) — measure tick/FPS, free internal h | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 13.9 | — / 91KB | — / 46KB | -| `pc-macos` | — / 19,608-26,316 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 11.5-13.9 | — / 91KB-94KB | — / 46KB-62KB | +| `pc-macos` | — / 4,975-28,571 | — / unlimited | — / unlimited | | `pc-windows` | — / 8,696-9,174 | — / unlimited | — / unlimited | -- `esp32-eth`: observed 2026-06-07 -- `pc-macos`: observed 2026-06-07 +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-11 - `pc-windows`: observed 2026-06-07 ### scenario_Layer_base_pipeline -`test/scenarios/light/scenario_Layer_base_pipeline.json` — Core pipeline: build Layouts→Grid→Layer→RainbowEffect→Drivers→ArtNetSendDriver from scratch and verify each module wires correctly. Drives the bounded FPS check at the end so a render-path regression is caught. +`test/scenarios/light/scenario_Layer_base_pipeline.json` — Core pipeline: build Layouts→Grid→Layer→RainbowEffect→Drivers→NetworkSendDriver from scratch and verify each module wires correctly. Drives the bounded FPS check at the end so a render-path regression is caught. -**Mode**: `construct` · **Also touches**: GridLayout, RainbowEffect, Drivers, ArtNetSendDriver +**Mode**: `construct` · **Also touches**: GridLayout, RainbowEffect, Drivers, NetworkSendDriver #### `add-artnet` (add_module) 📏 -Add ArtNetSendDriver and run the bounded FPS measurement (expected to stay at >=80% of the rated FPS for the 128x128 grid this scenario builds; min_pct needs a live baseline, so it gates only on hardware and is skipped with a WARN in the desktop runner). +Add NetworkSendDriver and run the bounded FPS measurement (expected to stay at >=80% of the rated FPS for the 128x128 grid this scenario builds; min_pct needs a live baseline, so it gates only on hardware and is skipped with a WARN in the desktop runner). **Setup** (preceding non-measured steps): - `add-layout-group` (add_module) — Create the top-level Layouts container. @@ -1351,7 +1447,7 @@ Add ArtNetSendDriver and run the bounded FPS measurement (expected to stay at >= `test/scenarios/light/scenario_Layer_buildup.json` — Start empty, add modules step by step, measure tick + heap after each meaningful pipeline state. Surfaces 'how much does each module cost?' so a regression in any one module shows up as a per-step delta instead of a single end-to-end number. Heap bounds catch unintended allocations: each step's delta vs the previous step is asserted against max_delta_bytes (only meaningful on ESP32 where freeHeap() returns a real value). -**Mode**: `construct` · **Also touches**: Layouts, GridLayout, RainbowEffect, MultiplyModifier, Drivers, ArtNetSendDriver +**Mode**: `construct` · **Also touches**: Layouts, GridLayout, RainbowEffect, MultiplyModifier, Drivers, NetworkSendDriver #### `measure-minimum` (measure) 📏 @@ -1379,7 +1475,7 @@ Full pipeline at 16x16. Heap delta vs previous measure-minimum step should stay **Setup** (preceding non-measured steps): - `add-drivers` (add_module) — Drivers container wired to the Layer. -- `add-artnet` (add_module) — ArtNetSendDriver under Drivers. Full pipeline now end-to-end. +- `add-artnet` (add_module) — NetworkSendDriver under Drivers. Full pipeline now end-to-end. **Bounds**: - heap growth ≤ 8192B vs previous measure step @@ -1444,7 +1540,7 @@ Production-size grid with the full pipeline. Final tick + cumulative heap delta #### `add-artnet` (add_module) 📏 -Add ArtNetSendDriver and run the bounded FPS measurement on the no-LUT path. +Add NetworkSendDriver and run the bounded FPS measurement on the no-LUT path. **Setup** (preceding non-measured steps): - `add-layout-group` (add_module) — Create the top-level Layouts container. @@ -1470,7 +1566,7 @@ Add ArtNetSendDriver and run the bounded FPS measurement on the no-LUT path. `test/scenarios/light/scenario_modifier_swap.json` — Swap the Layer's modifier between Multiply and Checkerboard and verify the pipeline stays live across each replace. Prepares its own canvas (clear + rebuild) so it runs from any device state: one Layout(Grid 32x32) + one Layer + one effect + one modifier, then replace_module cycles the modifier MOD slot Multiply -> Checkerboard -> Multiply, measuring after each so a broken swap (null buffer / wrong light count) shows up. Exercises the modifier-replace path the UI's drag-replace uses. -**Mode**: `mutate` · **Also touches**: MultiplyModifier, CheckerboardModifier, NoiseEffect, Layouts, GridLayout, Drivers, ArtNetSendDriver, PreviewDriver +**Mode**: `mutate` · **Also touches**: MultiplyModifier, CheckerboardModifier, NoiseEffect, Layouts, GridLayout, Drivers, NetworkSendDriver, PreviewDriver #### `multiply-1` (measure) 📏 @@ -1490,11 +1586,11 @@ Multiply modifier active — pipeline live, LUT folds the grid. | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 1,580-7,752 | — / 172KB-204KB | — / 76KB-108KB | -| `pc-macos` | — / 142,857-166,667 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 1,580-7,752 | — / 172KB-225KB | — / 76KB-108KB | +| `pc-macos` | — / 76,923-166,667 | — / unlimited | — / unlimited | - `esp32-eth`: observed 2026-06-07 → 2026-06-08 -- `pc-macos`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 → 2026-06-11 #### `checkerboard` (measure) 📏 @@ -1507,11 +1603,11 @@ Checkerboard modifier active — masks half the lights; pipeline stays live (dri | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 778-990 | — / 170KB-203KB | — / 76KB-108KB | -| `pc-macos` | — / 50,000-58,824 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 769-990 | — / 170KB-225KB | — / 76KB-108KB | +| `pc-macos` | — / 17,544-58,824 | — / unlimited | — / unlimited | - `esp32-eth`: observed 2026-06-07 → 2026-06-08 -- `pc-macos`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-11 #### `multiply-2` (measure) 📏 @@ -1524,11 +1620,11 @@ Back to Multiply — replace round-trips cleanly, pipeline live again. | Board | FPS | heap | block | |---|---|---|---| -| `esp32-eth` | — / 1,587-2,278 | — / 169KB-204KB | — / 76KB-108KB | -| `pc-macos` | — / 125,000-166,667 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 1,587-2,278 | — / 169KB-225KB | — / 76KB-108KB | +| `pc-macos` | — / 66,667-166,667 | — / unlimited | — / unlimited | - `esp32-eth`: observed 2026-06-07 → 2026-06-08 -- `pc-macos`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-11 ## Layouts @@ -1536,7 +1632,7 @@ Back to Multiply — replace round-trips cleanly, pipeline live again. `test/scenarios/light/scenario_Layouts_mutation.json` — Tree mutation on the Layouts container while the pipeline runs: add a second layout (multiple layouts under one Layouts), replace a layout with a different type, and remove a layout. The check is that each mutation leaves the pipeline RENDERING — Layer + Drivers re-wire via buildState and the buffer stays non-null and non-zero. Mirrors the HTTP add/replace/delete handlers; exercises the runner's add_module / replace_module / remove_module ops. NOTE: the Layer renders a dense bounding-box buffer sized by the layouts' coordinate EXTENT, not the summed light count — layouts that overlap in coordinate space share voxels (two 64x64 grids both occupy x,y in 0..63). There are no per-layout coordinate offsets, so multiple layouts share the same coordinate box; these steps assert liveness, not buffer-size arithmetic. Grids are 64x64 so the tick stays above the host's microsecond clock at every step. -**Mode**: `mutate` · **Also touches**: GridLayout, SphereLayout, Layer, RainbowEffect, Drivers, ArtNetSendDriver +**Mode**: `mutate` · **Also touches**: GridLayout, SphereLayout, Layer, RainbowEffect, Drivers, NetworkSendDriver #### `measure-one-layout` (measure) 📏 @@ -1549,9 +1645,11 @@ Baseline: a single 64x64 grid layout drives the pipeline. | Board | FPS | heap | block | |---|---|---|---| +| `esp32-eth` | — / 41,667 | — / 224KB | — / 108KB | | `pc-macos` | — / 29,412-125,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 32,258-37,037 | — / unlimited | — / unlimited | +- `esp32-eth`: observed 2026-06-08 - `pc-macos`: observed 2026-06-05 - `pc-windows`: observed 2026-06-07 @@ -1569,10 +1667,12 @@ Pipeline still renders with two layouts wired (buffer non-null, fps measurable). | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | — / 33,333-111,111 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 37,037 | — / 223KB | — / 108KB | +| `pc-macos` | — / 21,739-111,111 | — / unlimited | — / unlimited | | `pc-windows` | — / 16,393-23,810 | — / unlimited | — / unlimited | -- `pc-macos`: observed 2026-06-05 +- `esp32-eth`: observed 2026-06-08 +- `pc-macos`: observed 2026-06-05 → 2026-06-11 - `pc-windows`: observed 2026-06-07 #### `measure-after-replace` (measure) 📏 @@ -1589,10 +1689,12 @@ Pipeline still renders after replacing a grid with a sphere (different layout ty | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | — / 8,621-100,000 | — / unlimited | — / unlimited | +| `esp32-eth` | — / 38,462 | — / 223KB | — / 108KB | +| `pc-macos` | — / 5,747-100,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 5,848-9,009 | — / unlimited | — / unlimited | -- `pc-macos`: observed 2026-06-05 → 2026-06-07 +- `esp32-eth`: observed 2026-06-08 +- `pc-macos`: observed 2026-06-05 → 2026-06-11 - `pc-windows`: observed 2026-06-07 #### `measure-after-remove` (measure) 📏 @@ -1609,9 +1711,11 @@ Pipeline renders with the single remaining grid, same as the baseline. | Board | FPS | heap | block | |---|---|---|---| +| `esp32-eth` | — / 41,667 | — / 224KB | — / 108KB | | `pc-macos` | — / 16,949-125,000 | — / unlimited | — / unlimited | | `pc-windows` | — / 33,333-38,462 | — / unlimited | — / unlimited | +- `esp32-eth`: observed 2026-06-08 - `pc-macos`: observed 2026-06-05 - `pc-windows`: observed 2026-06-07 @@ -1677,13 +1781,13 @@ Disable mirrorY. Mirror is now fully off — should land on the no-LUT path. | `esp32` | — / 4.4 | — / 88KB | — / 48KB | | `esp32-eth` | — / 8.9-9.0 | — / 132KB | — / 48KB-50KB | | `esp32-eth-wifi` | ≥ 10.0 / 11.1 | ≥ 103KB / 94KB | — / 48KB | -| `pc-macos` | ≥ 2,500 / 1,916-9,009 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 2,500 / 1,916-9,091 | unlimited / unlimited | — / unlimited | | `pc-windows` | — / 1,082-1,305 | — / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-08 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-10 - `pc-windows`: observed 2026-06-07 #### `re-enable-mirrorY` (set_control) 📏 @@ -1722,7 +1826,7 @@ Re-enable mirrorY and measure — the heavy LUT path must recover (FPS within 50 #### `add-artnet` (add_module) 📏 -Add ArtNetSendDriver and run the bounded FPS measurement on the LUT path. +Add NetworkSendDriver and run the bounded FPS measurement on the LUT path. **Setup** (preceding non-measured steps): - `add-layout-group` (add_module) — Create the top-level Layouts container. @@ -1749,11 +1853,11 @@ Add ArtNetSendDriver and run the bounded FPS measurement on the LUT path. `test/scenarios/light/scenario_MultiplyModifier_pipeline.json` — Pipeline with a mirror modifier: NoiseEffect renders one quadrant, MultiplyModifier reflects across X and Y to produce a kaleidoscope. Used to verify the MultiplyModifier wires into Layer cleanly and that the full pipeline still meets its FPS bound. -**Mode**: `construct` · **Also touches**: Layer, NoiseEffect, ArtNetSendDriver +**Mode**: `construct` · **Also touches**: Layer, NoiseEffect, NetworkSendDriver #### `add-artnet` (add_module) 📏 -Add ArtNetSendDriver and run the bounded FPS measurement (mirror + LUT path must stay at >=80% of the rated FPS). +Add NetworkSendDriver and run the bounded FPS measurement (mirror + LUT path must stay at >=80% of the rated FPS). **Setup** (preceding non-measured steps): - `add-layout-group` (add_module) — Create the top-level Layouts container. diff --git a/docs/tests/unit-tests.md b/docs/tests/unit-tests.md index 9508d5a3..a20826ef 100644 --- a/docs/tests/unit-tests.md +++ b/docs/tests/unit-tests.md @@ -4,21 +4,41 @@ Auto-generated from `test/unit/{core,light}/unit_*.cpp` by `scripts/docs/generat Unit tests are the fastest tier in the [test strategy](../testing.md): they run the production code in-process with doctest, no platform, no network. Each section below covers one module. -## ArtNetSendDriver - -`test/unit/light/unit_ArtNetSendDriver_no_alloc_in_loop.cpp` -*Also touches: Drivers, Correction.* - -- onBuildState sizes the correction-applied buffer to source-count × out-channels. The size matches what loop() needs on its first send. Calling loop() after onBuildState must not reallocate — pin the data pointer + shape. -- A preset toggle from RGB to RGBW grows outChannels from 3 to 4. The grow runs in onCorrectionChanged, off the hot path. -- A brightness-only change keeps outChannels at 3 — onCorrectionChanged is still called, but the resize short-circuits (existing buffer already fits). - -`test/unit/light/unit_ArtNetSendDriver_packet.cpp` - -- The built packet contains the exact header layout the Art-Net spec mandates: ID, OpCode, version, sequence, physical, universe, length, data. -- Universe 259 (0x0103) is encoded little-endian (low byte first), matching the Art-Net wire format. -- 256 RGB lights (768 bytes) split across exactly 2 universes (510 + 258), matching the 510-channel-per-universe cap. -- The data-length field is encoded big-endian (high byte first), unlike the universe field — matching the Art-Net spec. +## AudioModule + +`test/unit/light/unit_AudioBands.cpp` +*Also touches: AudioSpectrumEffect.* + +- _AudioBands: silence yields all-zero bands and no peak_ +- _AudioBands: a low tone lands in a low band, a high tone in a high band_ +- _AudioBands: the reported peak frequency tracks the played tone_ +- _AudioBands: a single tone concentrates energy, not smears it everywhere_ +- _AudioBands: noiseFloor gates a low idle spectrum to zero, gain scales it back_ +- _AudioBands: zero / degenerate input never crashes_ + +`test/unit/light/unit_AudioLevel.cpp` +*Also touches: AudioVolumeEffect.* + +- _DcBlocker: a constant DC offset is filtered out_ +- _DcBlocker: an audio tone passes through (DC removed, AC kept)_ +- _DcBlocker: reset clears state, null-safe_ +- _AudioLevel: silence reads zero_ +- _AudioLevel: pure DC reads zero (DC offset stripped)_ +- _AudioLevel: a loud sine reads a higher level than a quiet one_ +- _AudioLevel: DC bias does not change the level of a sine_ +- _AudioLevel: a high noiseFloor (dB floor) gates a modest signal to zero_ +- _AudioLevel: higher gain (narrower dB window) reads a higher level_ +- _AudioLevel: empty / null input is silence, never a crash_ +- _AudioLevel: isqrt64 matches floor(sqrt) on a spread of values_ +- Regression: the boot wiring in main.cpp does create("AudioModule")->markWiredByCode() and create() returns nullptr for an UNREGISTERED type — so a missing registerType made the deref crash and the device boot-looped (found on the S3 bench). These pin that AudioModule and the two audio effects are all registered + createable through the factory, and that latestFrame() is never null even with no mic (so a consumer added before the mic can't deref null). +- _AudioModule::latestFrame is never null (silent frame with no active mic)_ + +`test/unit/light/unit_AudioModule.cpp` + +- _AudioModule: a fresh, unconfigured module is idle (pins default unset)_ +- _AudioModule: setup/teardown is repeatable with no residual state_ +- _AudioModule: teardown clears the active mic (latestFrame falls back to silence)_ +- _AudioModule: last setup() wins, any add/remove order stays coherent_ ## BlendMap @@ -60,14 +80,16 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run ## CheckerboardEffect `test/unit/light/unit_CheckerboardEffect.cpp` -*Also touches: SpiralEffect, PlasmaPaletteEffect, RipplesEffect, GlowParticlesEffect, LavaLampEffect.* +*Also touches: SpiralEffect, PlasmaPaletteEffect, RingsEffect, RipplesEffect, GlowParticlesEffect, LavaLampEffect.* - Checkerboard paints at least one non-zero byte on a 16×16 grid (effect actually renders). - With cell_size=4, adjacent cells render different colours (the checker pattern is real, not uniform). -- LavaLampEffect has localised blob features that can land on identical corner palette indices at some t values (corner-pair check is too strict). Scan the whole buffer for any two distinct pixels instead — same approach as RipplesEffect below. LavaLamp paints at least one non-zero byte (effect actually renders). +- LavaLampEffect has localised blob features that can land on identical corner palette indices at some t values (corner-pair check is too strict). Scan the whole buffer for any two distinct pixels instead — same approach as RingsEffect below. LavaLamp paints at least one non-zero byte (effect actually renders). - Across 10 frames at bpm=60, at least one frame shows two distinct colours somewhere in the buffer (blobs move and the field varies). -- RipplesEffect has localised features (thin rings); corner-pair check is too strict, so we scan for any two distinct pixels instead. Ripples paints at least one non-zero byte (effect actually renders). -- At least two distinct pixels exist somewhere in the buffer (ripples are localised, so corner-pair would be too strict). +- RingsEffect has localised features (thin rings); corner-pair check is too strict, so we scan for any two distinct pixels instead. Rings paints at least one non-zero byte (effect actually renders). +- At least two distinct pixels exist somewhere in the buffer (rings are localised, so corner-pair would be too strict). +- RipplesEffect (MoonLight sine-wave water surface) lights one pixel per column at a sine-driven height. On a flat 2D layer it still paints a visible wavefront — assert it renders something and varies across the surface. +- Ripples lights one pixel per column at a sine-driven height, so the surface holds at least two distinct colours (wavefront vs background) — scan the whole buffer, corner-pair would be too strict. ## CheckerboardModifier @@ -211,7 +233,7 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - REGRESSION: a high fan-out Multiply (8×8×4 = 256) on a 128×128 grid must build a NON-EMPTY LUT that covers every physical light. The maxDest estimate (logicalCount × maxMultiplier) is computed in 64-bit; before that fix it overflowed uint16 on no-PSRAM boards (256 × 256 = 65536 wraps to 0), sized the LUT to ~nothing, and blanked the display. Here we assert the LUT actually maps the full light set, in range — the symptom that black-screened the device. `test/unit/light/unit_Layer_zero_grid.cpp` -*Also touches: RainbowEffect, NoiseEffect, PlasmaEffect, CheckerboardEffect, SpiralEffect, MetaballsEffect, PlasmaPaletteEffect, RipplesEffect, GlowParticlesEffect, LavaLampEffect, FireEffect, ParticlesEffect.* +*Also touches: RainbowEffect, NoiseEffect, PlasmaEffect, CheckerboardEffect, SpiralEffect, MetaballsEffect, PlasmaPaletteEffect, RingsEffect, RipplesEffect, GlowParticlesEffect, LavaLampEffect, FireEffect, ParticlesEffect.* - Rainbow on 0,0,0 grid: no crash. - Noise on 0,0,0 grid: no crash. @@ -220,6 +242,7 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - Spiral on 0,0,0 grid: no crash. - Metaballs on 0,0,0 grid: no crash. - PlasmaPalette on 0,0,0 grid: no crash. +- Rings on 0,0,0 grid: no crash. - Ripples on 0,0,0 grid: no crash. - GlowParticles on 0,0,0 grid: no crash. - LavaLamp on 0,0,0 grid: no crash. @@ -255,6 +278,31 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - Disabling the only layout child and re-enabling it must not crash Drivers, and rendering resumes cleanly. +## LcdLedDriver + +`test/unit/light/unit_LcdLedDriver.cpp` +*Also touches: Drivers, Correction.* + +- Explicit counts slice the buffer consecutively; the frame is sized by the LONGEST lane. The bus always has all 8 lanes — unused strands take the 0-light remainder and idle LOW. +- Empty ledsPerPin splits evenly — same PinList semantics the RMT driver uses. +- An RGB→RGBW preset toggle grows the frame (32 vs 24 slot bytes per light). +- A bad pin list idles the driver with the parse literal in the status; fixing it recovers. +- Pins now default UNSET (the "default only when it cannot do harm" rule — the strand is user-soldered). A fresh, unconfigured driver idles, never grabbing the 8 data GPIOs on its own. (wire() back-fills empty pins for the slicing cases, so this one wires the buffer directly to keep pins empty.) +- IDF's i80 bus rejects partial pin sets, so the driver does too — fewer than 8 pins is a config error, not a narrower bus. +- A 0×0×0 grid is a clean idle: zero counts, zero frame (no pad for an empty frame), no crash. +- setup/teardown cycles leave no residue (status clean, ASAN-checked heap). +- loopbackRxPin is bound always, visible only while loopbackTest is on. + +`test/unit/light/unit_LcdLedEncoder.cpp` +*Also touches: Correction.* + +- One lane, one byte 0xA5: slot0 always the mask, slot1 follows the bits MSB-first, slot2 always zero. +- Two lanes 0xFF/0x00 in one row: the data slot carries lane 0's bit only — the transpose itself. +- A lane excluded from the mask contributes to NEITHER slot 0 nor slot 1, even with garbage wire bytes — short strands idle LOW (no white flashes). +- Mask 0 (a row past every lane's strand) is a fully idle row. +- Channel order comes from Correction (logical red → GRB wire {0,255,0}); the encoder is order-agnostic. +- RGBW rows emit 4 channels × 8 bits × 3 slots = 96 bytes. + ## MappingLUT `test/unit/core/unit_MappingLUT.cpp` @@ -363,6 +411,54 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - parseDottedQuad (in Control.h) is the validator on every IPv4 write, over both the HTTP API and persistence. Pin the contract. - The static-IP fields (ip / gateway / subnet / dns) are bound as IPv4 controls — 4 bytes of storage each, not 16-char dotted-quad strings. They start hidden because addressing defaults to DHCP. - In WiFi-capable builds (anything other than --firmware esp32-eth), the rssi and txPower controls are present and start hidden — Idle/Ethernet don't expose live WiFi metrics. The Ethernet-only build compiles them out entirely so the iteration finds nothing, which is still a valid pass shape. +- Conditional controls: the static-IP fields (ip/gateway/subnet/dns) are visible only when addressing == Static (1), hidden under DHCP (0) — but ALWAYS bound so persistence can load a saved static config regardless of the live mode. This is the documented add-then-setHidden pattern (architecture.md § Conditional controls); the test pins it both ways so a regression (e.g. dropping setHidden, or conditionally NOT adding the field) fails here, not on hardware. + +## NetworkReceiveEffect + +`test/unit/light/unit_NetworkReceiveEffect.cpp` +*Also touches: NetworkSendDriver.* + +- A packet built by the sender's builder parses back to the same universe and payload — the two sides can't drift. +- Bad magic, non-OpDmx opcodes, truncated headers, and lying length fields are all rejected — the receiver drops them. +- Universe universe_start lands at byte 0; the next universe lands at byte 510 — the same split the sender uses. +- The layer clears its buffer every tick; staging holds the last frame, so the lights don't strobe black between packets. +- Universes below universe_start are ignored; universes relative to a non-zero start land at offset 0. +- A payload overrunning the buffer end is clamped; a universe entirely beyond the buffer is ignored. +- A 0×0×0 grid accepts packets as a clean no-op — degraded, not crashed. +- Staging is sized in onBuildState (off the hot path), loop() never reallocates it, teardown frees it. +- A real packet sent over localhost UDP lands in the layer buffer — the end-to-end proof of the platform receive path. + +`test/unit/light/unit_NetworkReceiveEffect_protocols.cpp` +*Also touches: NetworkSendDriver.* + +- A packet built by the sender's builder parses back to the same universe and payload — the two sides can't drift. +- Truncated headers, a bad ACN identifier, wrong layer vectors, a non-zero start code, and a lying property count are all rejected. +- A packet built by the sender's builder parses back to the same byte offset and payload. +- Truncated headers, wrong version bits, and a lying length field are rejected. +- Each universe-protocol parser refuses the other protocols' datagrams — port mix-ups degrade to silence, not garbage. +- An ArtPoll datagram is recognised (the discovery hook Resolume/Madrix use); OpDmx and non-ArtNet packets are not polls. +- The ArtPollReply carries the fields controllers read: opcode, IP, port, names, universe switches, MAC. +- DDP's byte addressing lands payloads at the exact offset; out-of-range and overflowing offsets are clamped or dropped. +- channels_per_universe = 512 maps universes at 512-byte strides and clamps a 512-channel payload to its slot. +- Three senders — one per protocol — hit the same effect on its three ports; each payload lands. The autodetect proof. + +## NetworkSendDriver + +`test/unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp` +*Also touches: Drivers, Correction.* + +- onBuildState sizes the correction-applied buffer to source-count × out-channels. The size matches what loop() needs on its first send. Calling loop() after onBuildState must not reallocate — pin the data pointer + shape. +- A preset toggle from RGB to RGBW grows outChannels from 3 to 4. The grow runs in onCorrectionChanged, off the hot path. +- A brightness-only change keeps outChannels at 3 — onCorrectionChanged is still called, but the resize short-circuits (existing buffer already fits). + +`test/unit/light/unit_NetworkSendDriver_packet.cpp` + +- The built packet contains the exact header layout the Art-Net spec mandates: ID, OpCode, version, sequence, physical, universe, length, data. +- Universe 259 (0x0103) is encoded little-endian (low byte first), matching the Art-Net wire format. +- 256 RGB lights (768 bytes) split across exactly 2 universes (510 + 258), matching the 510-channel-per-universe cap. +- The data-length field is encoded big-endian (high byte first), unlike the universe field — matching the Art-Net spec. +- The built E1.31 packet carries the exact ACN layout strict sACN receivers (and tools like xLights) validate: identifier, the three flags+length fields, CID, source name, priority, universe, property count, start code. +- The built DDP packet carries version+push bits, RGB data type, default destination, and big-endian offset/length. ## NoiseEffect @@ -375,6 +471,23 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - With depth > 1, adjacent and distant z-slices each render differently (3D noise, not a stack of identical 2D slices). - Same z-slice variation requirement holds for Plasma — each depth plane renders differently. +## ParlioLedDriver + +`test/unit/light/unit_ParlioLedDriver.cpp` +*Also touches: Drivers, Correction.* + +- Three lanes (Parlio accepts any 1..8 count) slice the buffer consecutively; the frame is sized by the LONGEST lane. +- Empty ledsPerPin (the default) splits evenly over the 8 lanes — shared PinList semantics, same as the RMT/LCD drivers. +- The Parlio-vs-LCD difference: 1..8 pins are ALL valid (no exactly-8 rule). +- More than 8 pins is rejected (the chip's lane cap), like the other drivers. +- An RGB→RGBW preset toggle grows the frame (32 vs 24 slot bytes per light). +- A bad pin list idles the driver with the parse literal in the status; fixing it recovers. +- Pins now default UNSET (the "default only when it cannot do harm" rule — the strand is user-soldered). A fresh, unconfigured driver idles, never grabbing a GPIO. (wire() back-fills empty pins for the slicing cases, so this one wires the buffer directly to keep pins empty.) +- A 0×0×0 grid is a clean idle: zero counts, zero frame, no crash. +- loop() is crash-safe across single-pin / multi-pin / pre-init configs (the transmit path is gated out on the host; this pins the reachable contract). +- setup/teardown cycles leave no residue (status clean, ASAN-checked heap). +- loopbackRxPin is bound always, visible only while loopbackTest is on. + ## ParticlesEffect `test/unit/light/unit_ParticlesEffect.cpp` @@ -411,6 +524,50 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - Pixel (0,0) is at full saturation and value (one channel exactly 255) — confirms hsvToRgb wiring. - Distant pixels carry different hues (the rainbow gradient is spatial, not uniform). +## RmtLedDriver + +`test/unit/light/unit_RmtLedDriver_lifecycle.cpp` +*Also touches: Drivers, Correction.* + +- _RmtLedDriver sizes the symbol buffer in onBuildState_ +- _RmtLedDriver keeps the symbol buffer across a rebuild (reinit must not free it)_ +- _RmtLedDriver keeps the symbol buffer across a pins change_ +- _RmtLedDriver grows the symbol buffer when the grid grows_ +- _RmtLedDriver releases the symbol buffer on teardown_ +- MoonModule contract: teardown reverses setup, so setup→teardown→setup→teardown cycles leave no residue — no leaked heap (ASAN in the test runner catches that), no stuck state. After each teardown the driver must look untouched: no symbol buffer, no status. Run several cycles to surface any accumulation. +- Conditional control: loopbackRxPin is visible only while loopbackTest is on, hidden otherwise — but always bound (so a saved rxPin loads regardless). Same add-then-setHidden pattern as NetworkModule (architecture.md § Conditional controls). This pins the exact behavior that, with the old UI, showed the pin at the wrong times; a regression in the C++ flag now fails here. +- Editing `pins` while the loopback test is ON must refresh the parsed config before the self-test runs — onUpdate fires before the buildState sweep re-parses, so without the in-branch parseConfig() the test would transmit on the OLD pin and show a verdict for it. Mirrors the fix in ParallelLedDriver; this pins the RMT sibling that the dedup left behind. Host-observable via pinCount(): the refresh re-parses to the new pin set even though the platform loopback itself is inert. + +`test/unit/light/unit_RmtLedDriver_pins.cpp` +*Also touches: Drivers, Correction.* + +- "18,17,16" parses to three pins in list order — the order defines the buffer slices. +- A single pin (the default "18") and spaces around tokens are both fine. +- _parsePinList rejects bad input with a static error message_ +- maxPins is the chip's RMT TX-channel cap: 5 pins fail an S3-sized 4, fit a classic 8. +- The same GPIO twice would double-drive one strand — rejected at parse time. +- Explicit "100,100,50" maps one count to each pin by position. +- A short list assigns what it names; unlisted pins share the remaining lights evenly. +- _assignCounts with an empty list splits evenly, last pin takes the rounding remainder_ +- _assignCounts clamps so the sum never exceeds the buffer_ +- _assignCounts handles a zero-light buffer (0×0×0 grid) as all-zero_ +- _assignCounts rejects a bad token_ +- _assignCounts ignores extra counts beyond the pin list_ +- _RmtLedDriver slices the buffer across pins (even split)_ +- _RmtLedDriver slices the buffer per ledsPerPin_ +- _RmtLedDriver idles with a status error on a bad pin list_ +- _RmtLedDriver with the empty default pins idles cleanly (no pin assumed)_ +- _RmtLedDriver re-slices when the source buffer changes_ +- loop() is a safe no-op across single-pin, multi-pin and zero-grid configs. + +`test/unit/light/unit_RmtLedEncoder.cpp` +*Also touches: Correction.* + +- _encoder: one byte, MSB-first, 0 and 1 bits get the right pulse widths_ +- _encoder: one light's channels emit channels*8 symbols in byte order_ +- _encoder: GRB ordering comes from Correction, encoder is order-agnostic_ +- _encoder: RGBW preset yields 32 symbols per light_ + ## Scheduler `test/unit/core/unit_Scheduler_unique_names.cpp` diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt index 2babf911..fa503f7b 100644 --- a/esp32/main/CMakeLists.txt +++ b/esp32/main/CMakeLists.txt @@ -10,10 +10,20 @@ idf_component_register( "../../src/platform/esp32/platform_esp32_fs.cpp" "../../src/platform/esp32/platform_esp32_ota.cpp" "../../src/platform/esp32/platform_esp32_improv.cpp" + "../../src/platform/esp32/platform_esp32_rmt.cpp" + "../../src/platform/esp32/platform_esp32_lcd.cpp" + "../../src/platform/esp32/platform_esp32_parlio.cpp" + "../../src/platform/esp32/platform_esp32_i2s.cpp" INCLUDE_DIRS "../../src" "../../src/platform/esp32" ) +# Note: no explicit REQUIRES — the component relies on IDF's default common +# requirements, which already transitively expose driver/, esp_timer, nvs_flash, +# esp_https_ota, esp_driver_rmt, etc. Adding an explicit REQUIRES switches the +# component to strict mode and would hide every implicitly-available component, +# breaking the existing files. esp_driver_rmt's include dir is already on the +# compile line via the common set, so platform_esp32_rmt.cpp finds driver/rmt_*.h. target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/esp32/main/idf_component.yml b/esp32/main/idf_component.yml index 75677b22..2e33e22e 100644 --- a/esp32/main/idf_component.yml +++ b/esp32/main/idf_component.yml @@ -5,3 +5,33 @@ dependencies: version: "^1.16.0" improv/improv: version: "^1.2.5" + # IP101 Ethernet PHY for the ESP32-P4-NANO. IDF v6 moved the per-PHY drivers + # out of esp_eth core into managed components; only referenced on the P4 build + # (ethInit's IP101 ctor is behind if constexpr). The generic PHY (Olimex + # LAN8720) stays in core, so non-P4 builds don't pull this. + espressif/ip101: + version: "^1.0.0" + # esp-dsp — Espressif's signal-processing library; we use its float radix-2 FFT + # (dsps_fft2r_fc32) for the microphone spectrum. Float (not fixed-point) because + # every mic-capable target here has an FPU, where float is faster. Referenced + # only from platform_esp32_i2s.cpp's audioFft. + espressif/esp-dsp: + version: "^1.5.0" + # esp_wifi_remote + esp_hosted — WiFi for the ESP32-P4 via the on-board ESP32-C6 + # co-processor over SDIO. The P4 has no native radio; these present the C6's + # radio through the standard esp_wifi_* API (API-compatible, so the WiFi seam in + # platform_esp32.cpp is unchanged; esp_hosted self-inits at boot, no bring-up + # code needed). DELIBERATE v6.0-floor exception (docs/building.md § ESP-IDF + # version): these are managed components outside mainline v6.0, accepted + # explicitly and scoped to the P4. The `rules` gate pulls them ONLY on the + # esp32p4 build, so classic/S3 and the eth-only P4 build don't fetch or compile + # them. Versions pinned to patch-level (the versions validated on the P4-NANO + # bench) so the P4 build doesn't silently drift to a new minor. + espressif/esp_wifi_remote: + version: "~1.6.1" + rules: + - if: "target == esp32p4" + espressif/esp_hosted: + version: "~2.12.9" + rules: + - if: "target == esp32p4" diff --git a/esp32/partitions/esp32p4_16mb.csv b/esp32/partitions/esp32p4_16mb.csv new file mode 100644 index 00000000..dcea2990 --- /dev/null +++ b/esp32/partitions/esp32p4_16mb.csv @@ -0,0 +1,10 @@ +# Partition table for the Waveshare ESP32-P4-NANO (16 MB flash, 32 MB PSRAM). +# Same 16 MB layout as the S3-N16R8 board: app0 / app1 are 4 MB each (full +# projectMM binary with headroom), spiffs gets the remaining ~8 MB for LittleFS. +# +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x400000, +app1, app, ota_1, 0x410000, 0x400000, +spiffs, data, spiffs, 0x810000, 0x7F0000, diff --git a/esp32/sdkconfig.defaults.esp32p4-eth b/esp32/sdkconfig.defaults.esp32p4-eth new file mode 100644 index 00000000..26efa205 --- /dev/null +++ b/esp32/sdkconfig.defaults.esp32p4-eth @@ -0,0 +1,32 @@ +# Waveshare ESP32-P4-NANO — Ethernet-only build (IP101 PHY). +# Board: https://www.waveshare.com/wiki/ESP32-P4-Nano-StartPage +# Schematic / pin map confirmed against the Waveshare wiki + the ESPHome +# device page (devices.esphome.io/devices/waveshare-esp32-p4-nano). +# +# Layered onto sdkconfig.defaults by scripts/build/build_esp32.py. +# +# The RMII / PHY *pins* are NOT set here — they live in C (the per-target +# ethPins struct in src/platform/esp32/platform_config.h, read by ethInit), +# so the pin map stays in one place. This fragment only enables the EMAC + the +# RMII interface + flash / PSRAM / partition layout. (IP101 addr 1, MDC 31, +# MDIO 52, reset 51, external RMII clock IN on GPIO 50 — see the struct.) + +# Custom partition table — 16 MB layout (app0/app1 = 4 MB each, spiffs = 8 MB), +# same shape as the S3-N16R8 board. +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/esp32p4_16mb.csv" + +# Flash size override (P4-NANO ships 16 MB). +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y + +# PSRAM (P4-NANO has 32 MB; the P4 uses a HEX/200 MHz PSRAM controller, not the +# S3's octal mode). Round 1 makes no large allocations, so PSRAM is enabled for +# later rounds but not relied on at boot. +CONFIG_SPIRAM=y + +# Ethernet — on-chip EMAC + RMII. Pins come from ethPins (C), not sdkconfig. +CONFIG_ETH_USE_ESP32_EMAC=y +CONFIG_ETH_PHY_INTERFACE_RMII=y +CONFIG_ETH_DMA_BUFFER_SIZE=512 +CONFIG_ETH_DMA_RX_BUFFER_NUM=10 +CONFIG_ETH_DMA_TX_BUFFER_NUM=10 diff --git a/esp32/sdkconfig.defaults.esp32p4-eth-wifi b/esp32/sdkconfig.defaults.esp32p4-eth-wifi new file mode 100644 index 00000000..8e3f6c03 --- /dev/null +++ b/esp32/sdkconfig.defaults.esp32p4-eth-wifi @@ -0,0 +1,54 @@ +# Waveshare ESP32-P4-NANO: Ethernet + WiFi build. +# Board: https://www.waveshare.com/wiki/ESP32-P4-Nano-StartPage +# +# Layered onto sdkconfig.defaults + sdkconfig.defaults.esp32p4-eth by +# scripts/build/build_esp32.py. The eth fragment brings the EMAC / RMII / flash / +# PSRAM / partition layout; this fragment adds WiFi over the on-board ESP32-C6 +# co-processor. +# +# The P4 has no native radio. WiFi comes from the C6 reached over SDIO, presented +# to the P4 as the standard esp_wifi_* API by the esp_wifi_remote + esp_hosted +# managed components (pulled P4-only via the `rules` gate in main/idf_component.yml). +# This is a DELIBERATE v6.0-floor exception (see docs/building.md § ESP-IDF version). +# No bring-up code is needed in the firmware: esp_hosted self-initialises at boot +# via a constructor and connects to the C6 on its own. ensureWifiInit() in +# src/platform/esp32/platform_esp32.cpp explicitly does NOT call esp_hosted_init / +# connect_to_slave (an earlier prelude that did was removed: it reset the slave and +# tore down the live SDIO link, bench-proven 2026-06-12). The co-processor firmware +# read-out is gated on platform::hasWifiCoprocessor. +# +# Values below mirror Espressif's own P4 reference fragment: +# managed_components/espressif__esp_hosted/examples/host_network_split__power_save/ +# sdkconfig.defaults.esp32p4 (+ the example's shared sdkconfig.defaults). + +# --- Co-processor = ESP32-C6. Two SEPARATE Kconfig choices must both point at the +# C6, in this order, or the build fails: +# 1. SLAVE_IDF_TARGET_ESP32C6 (esp_wifi_remote's slave select). It auto-defaults +# to C6 on a P4, but esp_hosted's own CP-target choice does NOT track it, so +# we set it explicitly to be unambiguous. +# 2. ESP_HOSTED_CP_TARGET_ESP32C6 (esp_hosted's co-processor choice). This option +# `depends on SLAVE_IDF_TARGET_ESP32C6` — so without (1), only the dependency- +# free ESP32-H2 option is selectable and the choice silently falls to H2 (a +# WiFi-less part). That cascade is what dropped FUNC_BOARD to _NONE and left +# the WIFI_RMT_* symbols undefined (the build failure chased down here). +CONFIG_SLAVE_IDF_TARGET_ESP32C6=y +CONFIG_ESP_HOSTED_CP_TARGET_ESP32C6=y + +# --- Co-processor board preset. With the C6 target selected above, the "P4 +# Function EV Board" preset becomes available and configures the SDIO transport + +# pins (CLK 18 / CMD 19 / D0-D3 14-17 / slave-reset 54), which match the NANO. +# BENCH-VERIFY: if connect-to-slave fails, the NANO may differ from the EV board +# preset; fall back to explicit SDIO pin config then. +CONFIG_ESP_HOSTED_P4_DEV_BOARD_FUNC_BOARD=y + +# --- Remote WiFi RX/TX buffer config. esp_wifi_remote's WIFI_INIT_CONFIG_DEFAULT() +# reads these CONFIG_WIFI_RMT_* symbols (the remote counterparts of the native +# CONFIG_ESP_WIFI_* buffers); without them the macro fails to compile. Values from +# the reference fragment. +CONFIG_WIFI_RMT_STATIC_RX_BUFFER_NUM=10 +CONFIG_WIFI_RMT_DYNAMIC_RX_BUFFER_NUM=32 +CONFIG_WIFI_RMT_DYNAMIC_TX_BUFFER_NUM=32 +CONFIG_WIFI_RMT_AMPDU_TX_ENABLED=y +CONFIG_WIFI_RMT_TX_BA_WIN=32 +CONFIG_WIFI_RMT_AMPDU_RX_ENABLED=y +CONFIG_WIFI_RMT_RX_BA_WIN=16 diff --git a/scripts/MoonDeck.md b/scripts/MoonDeck.md index cc235e2e..a99845ef 100644 --- a/scripts/MoonDeck.md +++ b/scripts/MoonDeck.md @@ -1,5 +1,11 @@ # MoonDeck Script Reference +MoonDeck is projectMM's browser-based developer console: one page that builds, flashes, runs, tests, monitors, and checks the project across every target, and discovers and drives devices on the network. Every action it offers is a thin wrapper around a script under `scripts/`, so the CLI (`uv run scripts//.py`) and MoonDeck run exactly the same code — agents typically use the CLI, humans use MoonDeck. For what MoonDeck *is* and where it sits in the workflow see [docs/building.md § MoonDeck](../docs/building.md#moondeck--the-dev-console); this page is the per-script reference. + +Launch it with `uv run scripts/moondeck.py` and open . The console has three tabs — **PC** (desktop build / run / test), **ESP32** (chip + port, build / flash / monitor), and **Live** (discovery and live runs against networked devices) — above a network bar and per-device board pickers. Script definitions live in `scripts/moondeck_config.json` (committed); runtime state (selected network, devices, ports) persists in `scripts/moondeck.json` (gitignored). + +Below: the UI behaviours common to every card, described once, then one section per script grouped by the tab it appears on. Each section gives the equivalent CLI invocation, so the page doubles as the command reference for running anything without the browser. + ## UI Features - **Status dots** on each card: grey (not run), orange (running), green (exit 0), red (exit non-zero). @@ -172,6 +178,29 @@ Executes scenario steps (add_module, set_control, delete_module) via REST API. C For a full description of each scenario, see the [scenario inventory](/api/docs/tests/scenario-tests.md) — auto-generated from the JSON files. +### 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. + +```bash +uv run scripts/scenario/run_network_live.py # full matrix over all online devices +uv run scripts/scenario/run_network_live.py --device MM-70BC # only rounds with this sender +uv run scripts/scenario/run_network_live.py --tolerance 1 # loosen the per-channel byte match +``` + +Everything it mutates (grid size → 16×16 for the run, NetworkSend `ip`/`protocol`/`enabled`, the temporarily added NetworkReceive effect) is restored afterwards, also on failure. Exit codes: `0` = all legs passed, `1` = a leg failed, `2` = environment problem (no online devices / no moondeck.json). Desktop listeners may need the OS firewall to allow UDP 6454/5568/4048. + +### run_network_roundtrip + +Minimal **PC→device→PC latency probe** across **all three protocols**: per device, the PC sends one solid-colour frame over ArtNet, then E1.31, then DDP, each time timing how long until that colour appears in the device's `/ws` preview stream (PC → NetworkReceiveEffect → PreviewDriver → PC). The receiver autodetects each protocol on its own port, so there's no device reconfig between them. Reports min / median / max over N repeats per protocol and a per-device median-per-protocol comparison line — the spread is the signal for the latency / hiccup symptom, the protocol comparison shows which transport is fastest on a given board, and running across boards makes the per-chip difference visible (a classic ESP32 measures slower than an S3). Runs against **every device checked in the Live tab** (the same `selected` set the matrix test uses); unreachable checked devices are warned and skipped. The measured time includes the PreviewDriver's own fps quantisation (≈42 ms at the 24 fps default), so it's "state visible within" latency, not wire latency; raise the device's Preview fps to tighten it. Deliberately minimal — per-frame sequence matching, the device→device chain, and jitter/drop histograms are left as later extensions. + +```bash +uv run scripts/scenario/run_network_roundtrip.py # every checked device, 10 probes each +uv run scripts/scenario/run_network_roundtrip.py --host 192.168.1.156 --repeats 20 # one explicit device +``` + +Captures and restores each device's grid and removes the temporary NetworkReceive on exit (also on failure). Exit codes match the matrix test: `0` = at least one device measured, `1` = none returned a frame, `2` = environment problem (no checked/reachable devices). + ## ESP32 Tab @@ -286,6 +315,15 @@ Push WiFi credentials to a running projectMM device over USB-serial. Uses the [I **One-click flow**: pick the device's port in MoonDeck, hit **Improv WiFi**. The script reads SSID + password from the **active network's WiFi block in `scripts/moondeck.json`** (the one shown in the network bar at the top of the sidebar). If that block is empty, it falls back to detecting the host machine's currently-joined WiFi (macOS Keychain / Linux NetworkManager / Windows `netsh`). The device replies with its new URL when STA comes up — typically 5-10 s end to end. +**Board dropdown (pre-association injection)**: pick your physical board next to the Firmware dropdown and the flow forwards `--board` — the script then resolves the board's `boards.json` settings and pushes the TX-power cap over the `SET_TX_POWER` vendor RPC **before** the credentials, plus `SET_BOARD` after success. This matters for brown-out-prone boards (LOLIN S3/S2, cap 8 dBm): at full TX power they fail their very first WiFi association, so the cap can't wait for the post-online HTTP injection. Leave the dropdown on "(any board)" for boards without special settings. + +```bash +# Equivalent CLI for a LOLIN S3 (cap resolved from boards.json): +uv run scripts/build/improv_provision.py --port /dev/cu.usbmodem-XXX --board "LOLIN S3 N16R8" +# Or set the cap explicitly without a catalog entry: +uv run scripts/build/improv_provision.py --port /dev/cu.usbmodem-XXX --tx-power 8 +``` + ```bash # Use host's currently-joined WiFi (one click in MoonDeck → equivalent CLI): uv run scripts/build/improv_provision.py --port /dev/tty.usbserial-XXXX @@ -308,7 +346,7 @@ for port in /dev/tty.usbserial-*; do done ``` -The host-WiFi reader lives at [scripts/build/host_wifi.py](build/host_wifi.py) and runs standalone for diagnosis (`python3 scripts/build/host_wifi.py` prints the resolved SSID + password). It first checks `scripts/moondeck.json`'s active network's `wifi` block; if empty, falls back to OS auto-detect. The first macOS auto-detect run pops a Keychain access dialog — the OS doing its job; we don't try to bypass it. The retired `scripts/build/wifi_credentials.json` source is gone — credentials now live per-network in moondeck.json, so moving the laptop between networks is just a dropdown switch. +The host-WiFi reader lives at [scripts/build/host_wifi.py](build/host_wifi.py) and runs standalone for diagnosis (`uv run scripts/build/host_wifi.py` prints the resolved SSID + password). It first checks `scripts/moondeck.json`'s active network's `wifi` block; if empty, falls back to OS auto-detect. The first macOS auto-detect run pops a Keychain access dialog — the OS doing its job; we don't try to bypass it. The retired `scripts/build/wifi_credentials.json` source is gone — credentials now live per-network in moondeck.json, so moving the laptop between networks is just a dropdown switch. Replaces v1's `deploy/wifi.py` + `deploy/flashfs.py --wifi` partition-baking flow — the device stays running, no flash mode required. Full module + protocol details: [docs/moonmodules/core/ImprovProvisioningModule.md](../docs/moonmodules/core/ImprovProvisioningModule.md). diff --git a/scripts/build/build_esp32.py b/scripts/build/build_esp32.py index 8f87cb55..8eebf2ca 100644 --- a/scripts/build/build_esp32.py +++ b/scripts/build/build_esp32.py @@ -33,6 +33,16 @@ # NOTE: esp_phy is NOT excluded — it provides RF/clock init the ESP32 EMAC # (Ethernet RMII) depends on. Excluding it leaves Ethernet stuck "started" # with no link. Only the genuinely WiFi-side components are dropped. +# +# NOTE on the P4 co-processor components (esp_hosted / esp_wifi_remote / eppp_link): +# the `rules: target == esp32p4` gate in main/idf_component.yml pulls them for ANY +# esp32p4 build, including the WiFi-less esp32p4-eth, because manifest rules can't +# see our eth-only flag. EXCLUDE_COMPONENTS does NOT drop them (the component +# manager resolves the managed dependency before the exclude applies). It's a +# *build-time* cost only: the linker dead-strips the unused code, so they add ~0 +# bytes of flash to esp32p4-eth (our coprocessorWifi() is the empty stub there, so +# no esp_hosted symbol is referenced — confirmed: their .text size is 0x0 in the +# .map). Left as-is rather than fought; see docs/backlog/backlog.md. ETH_ONLY_EXCLUDE = ["esp_wifi", "wpa_supplicant", "esp_coex"] # Firmware catalogue. Each entry describes one shipping firmware variant. @@ -89,6 +99,22 @@ "Half the flash of N16R8; the N16R8 binary overruns an 8 MB " "board, so N8R8 boards (LightCrafter etc.) need this variant.", }, + "esp32p4-eth": { + "chip": "esp32p4", + "fragments": ["sdkconfig.defaults", "sdkconfig.defaults.esp32p4-eth"], + "eth_only": True, + "description": "Waveshare ESP32-P4-NANO — Ethernet only (IP101 PHY). The " + "WiFi-less fallback; esp32p4-eth-wifi adds the C6 radio.", + }, + "esp32p4-eth-wifi": { + "chip": "esp32p4", + "fragments": ["sdkconfig.defaults", "sdkconfig.defaults.esp32p4-eth", + "sdkconfig.defaults.esp32p4-eth-wifi"], + "eth_only": False, + "description": "Waveshare ESP32-P4-NANO — Ethernet + WiFi. WiFi runs on the " + "on-board ESP32-C6 over SDIO (esp_wifi_remote + esp_hosted, " + "pulled P4-only). First build is longer (managed components).", + }, } # Deprecated --profile values → firmware, kept one release for callers that @@ -167,6 +193,15 @@ def idf_env(idf_path: Path) -> dict: if venv_path: env["IDF_PYTHON_ENV_PATH"] = str(venv_path) + # IDF's post-build gen_gdbinit.py reads ESP_ROM_ELF_DIR (export.sh sets it; + # this hand-built env must too). The step only re-runs when its inputs + # change, so the missing variable failed builds intermittently. + rom_elfs = Path.home() / ".espressif" / "tools" / "esp-rom-elfs" + if rom_elfs.exists(): + versions = sorted((d for d in rom_elfs.iterdir() if d.is_dir()), reverse=True) + if versions: + env["ESP_ROM_ELF_DIR"] = str(versions[0]) + os.sep + # Build PATH: venv bin + IDF tools + toolchains + existing PATH extra_paths = [] @@ -238,11 +273,15 @@ def firmware_cmake_args(firmware: str, release: str = "") -> list[str]: # out the WiFi paths (MM_ETH_ONLY → esp32/main/CMakeLists.txt). args.append("-DEXCLUDE_COMPONENTS=" + ";".join(ETH_ONLY_EXCLUDE)) args.append("-DMM_ETH_ONLY=1") - # Firmwares that don't include the .eth sdkconfig fragment have no EMAC - # config — the on-chip Ethernet headers (`eth_esp32_emac_config_t`, …) - # disappear, and platform_esp32.cpp's ethInit() won't compile. Set - # MM_NO_ETH so the source provides stub implementations instead. - has_eth_fragment = any(f.endswith(".eth") for f in spec["fragments"]) + # Firmwares that don't enable the EMAC have no on-chip Ethernet headers + # (`eth_esp32_emac_config_t`, …), so platform_esp32.cpp's ethInit() won't + # compile — set MM_NO_ETH and the source provides stubs instead. A variant + # "has Ethernet" when any fragment carries an EMAC-enabling sdkconfig: the + # classic Olimex fragment is `sdkconfig.defaults.eth` (".eth"); board-specific + # ones append "-eth" (e.g. ".esp32p4-eth"). Match either so a new eth board + # doesn't silently stub Ethernet out. + has_eth_fragment = any(f.endswith(".eth") or f.endswith("-eth") + for f in spec["fragments"]) if not has_eth_fragment: args.append("-DMM_NO_ETH=1") return args @@ -339,6 +378,17 @@ def main(): # builds the per-build-dir sdkconfig already has the chip pinned, so # set-target is skipped — switching to another firmware uses a different # build_dir entirely, so its sdkconfig is untouched. + # + # KNOWN ISSUE (esp32p4-eth-wifi): esp_wifi_remote's slave target + # (SLAVE_IDF_TARGET_ESP32C6) is selected by a Kconfig `default ... if + # IDF_TARGET_ESP32P4` that fires during `set-target` but is dropped by the + # reconfigure a plain `build` triggers, falling back to ESP32-H2 (no WiFi) and + # failing on missing CONFIG_WIFI_RMT_* symbols. A clean manual sequence works: + # rm -rf build/esp32-esp32p4-eth-wifi && idf.py -B -DSDKCONFIG=/sdkconfig \ + # -DSDKCONFIG_DEFAULTS="..." set-target esp32p4 && (same) build + # but this wrapper does not yet reproduce it reliably — tracked in + # docs/backlog/backlog.md (ESP32-P4 round 3). Until fixed, build this variant + # with the manual sequence above. extra = firmware_cmake_args(firmware, args.release) if not build_dir.exists(): print(f"Setting target to {chip} (firmware: {firmware}, build dir: " diff --git a/scripts/build/flash_esp32.py b/scripts/build/flash_esp32.py index 2c4a1da9..0a28b95d 100644 --- a/scripts/build/flash_esp32.py +++ b/scripts/build/flash_esp32.py @@ -40,6 +40,12 @@ def main(): "firmware must exist at build/esp32-/ — " "i.e. you must have run Build with the same " "--firmware first.") + parser.add_argument("--baud", type=int, default=460800, + help="esptool flash baud rate (default: 460800 — reliable on " + "every board). The web installer uses 921600 (~2x faster); " + "pass --baud 921600 to match it, but some USB bridges " + "(CP210x/CH340) drop to 'chip stopped responding' mid-flash " + "at that rate, so it's opt-in, not the default.") args = parser.parse_args() if not ESP32_DIR.exists(): @@ -77,7 +83,10 @@ def main(): "-DSDKCONFIG=" + str(build_dir / "sdkconfig"), ] - r = subprocess.run(cmd + b_arg + ["flash", "-p", args.port], + # -b sets the esptool flash baud (idf.py's own default is also 460800). + # --baud 921600 matches the web installer for ~2x speed, but isn't the + # default because some USB bridges can't sustain it (see --baud help). + r = subprocess.run(cmd + b_arg + ["flash", "-p", args.port, "-b", str(args.baud)], cwd=ESP32_DIR, env=env) if r.returncode == 0: _record_flash_event(args.port, args.firmware) diff --git a/scripts/build/improv_provision.py b/scripts/build/improv_provision.py index c6f331ca..2ad2963b 100644 --- a/scripts/build/improv_provision.py +++ b/scripts/build/improv_provision.py @@ -219,6 +219,19 @@ def main() -> int: "on Windows; empty for open networks)") ap.add_argument("--timeout", type=float, default=45.0, help="Max seconds to wait for a final response (default: 45)") + ap.add_argument("--board", default=None, metavar="NAME", + help="Board name from docs/install/boards.json (e.g. " + "'LOLIN S3 N16R8'). Resolves the board's TX-power cap " + "(controls.Network.txPowerSetting) automatically and " + "pushes the board name via SET_BOARD after " + "provisioning — the same injection the web installer " + "does. An explicit --tx-power overrides the lookup.") + ap.add_argument("--tx-power", type=int, default=None, metavar="DBM", + help="Send the SET_TX_POWER vendor RPC (0..21 whole dBm) " + "BEFORE the credentials. Required for boards whose LDO " + "browns out at full TX power (LOLIN S3/S2 → 8, see " + "docs/install/boards.json) — without it the very first " + "association fails and the cap can never arrive over HTTP.") args = ap.parse_args() if args.self_test: @@ -261,12 +274,52 @@ def main() -> int: print(f"ERROR: password too long ({len(args.password)} > 63 bytes)", file=sys.stderr) return 2 + if args.board: + # boards.json is the single source of truth for per-board injection — + # same file the web installer and MoonDeck read. + import json + from pathlib import Path + boards_file = Path(__file__).resolve().parents[2] / "docs" / "install" / "boards.json" + try: + catalog = json.loads(boards_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + print(f"ERROR: cannot read {boards_file}: {e}", file=sys.stderr) + return 2 + entry = next((b for b in catalog if b.get("name") == args.board), None) + if entry is None: + names = ", ".join(b.get("name", "?") for b in catalog) + print(f"ERROR: board {args.board!r} not in boards.json ({names})", + file=sys.stderr) + return 2 + cap = entry.get("controls", {}).get("Network", {}).get("txPowerSetting") + if args.tx_power is None and isinstance(cap, int): + args.tx_power = cap + print(f"==> board {args.board!r}: TX-power cap {cap} dBm from boards.json") + try: ser = serial.Serial(args.port, baudrate=115200, timeout=0.1) except serial.SerialException as e: print(f"ERROR: could not open {args.port}: {e}", file=sys.stderr) return 2 + if args.tx_power is not None: + if not 0 <= args.tx_power <= 21: + print(f"ERROR: --tx-power {args.tx_power} out of range 0..21", file=sys.stderr) + return 2 + # SET_TX_POWER vendor RPC (0xFD): [cmd][data_len=1][dBm]. Mirrors + # SET_BOARD's framing; the firmware persists + applies it before the + # association the credentials below will trigger. The 2.5 s pause lets + # the module's 1 Hz consumer pick the cap up first. + print(f"==> sending SET_TX_POWER {args.tx_power} dBm to {args.port}") + ser.write(build_frame(TYPE_RPC, bytes([0xFD, 1, args.tx_power]))) + ser.flush() + deadline = time.monotonic() + 5.0 + ack = parse_frame(ser, deadline) + if not (ack and ack[0] == TYPE_RPC_RESPONSE): + print("==> warning: no SET_TX_POWER ack (old firmware?) — continuing", + file=sys.stderr) + time.sleep(2.5) + print(f"==> sending WIFI_SETTINGS to {args.port} (SSID: {args.ssid!r})") payload = build_wifi_settings_payload(args.ssid, args.password) frame = build_frame(TYPE_RPC, payload) @@ -307,6 +360,16 @@ def main() -> int: idx += slen url = urls[0] if urls else "(no URL reported)" print(f"==> provisioned: {url}") + if args.board: + # SET_BOARD vendor RPC (0xFE): [cmd][1+len][len][name] — + # the same post-provision push the web installer does, so + # the device persists its physical-board identity. + name = args.board.encode("utf-8") + ser.write(build_frame(TYPE_RPC, + bytes([0xFE, 1 + len(name), len(name)]) + name)) + ser.flush() + time.sleep(0.5) # let the device's serial task consume it + print(f"==> pushed SET_BOARD {args.board!r}") ser.close() return 0 diff --git a/scripts/build/setup_esp_idf.py b/scripts/build/setup_esp_idf.py index b4495332..b580f65d 100644 --- a/scripts/build/setup_esp_idf.py +++ b/scripts/build/setup_esp_idf.py @@ -15,6 +15,26 @@ # execution policy unlocked, which we'd rather not assume. INSTALL_SCRIPT_NAME = "install.bat" if sys.platform == "win32" else "install.sh" +# The ESP-IDF commit every target (classic ESP32, S3, P4) has been validated +# against. This script can't move an existing checkout for you (it doesn't own +# the clone), but it warns loudly when the installed IDF differs — a silent +# `git pull` or a fresh shallow clone landing on a newer dev-branch commit is +# exactly what turns a green build red with no code change (see +# docs/backlog/backlog.md "ESP-IDF version pinning"). To pin: in ~/esp/esp-idf, +# `git fetch && git checkout `. Migrating off this dev snapshot to a +# stable tag (v6.1 lands 2026-07-31) is a deliberate re-test pass, not a pull. +PINNED_IDF_COMMIT = "d1b91b79b5ff12d9d4b21fe1cf5406ab6044b8ff" +PINNED_IDF_VERSION = "v6.1-dev-399-gd1b91b79b5" + + +def _installed_idf_commit(idf_path: Path) -> str: + try: + r = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(idf_path), + capture_output=True, text=True) + return r.stdout.strip() if r.returncode == 0 else "" + except OSError: + return "" + def main(): idf_path = find_idf() @@ -31,6 +51,17 @@ def main(): if version_file.exists(): print(f"Version: {version_file.read_text(encoding='utf-8').strip()}") + # Drift warning: the build was validated against a specific IDF commit, and + # the dev branch this snapshot lives on moves. A mismatch isn't fatal (you + # may be deliberately migrating), but it must be visible. + installed = _installed_idf_commit(idf_path) + if installed and installed != PINNED_IDF_COMMIT: + print(f"\n⚠ IDF commit drift: installed {installed[:12]} != " + f"pinned {PINNED_IDF_COMMIT[:12]} ({PINNED_IDF_VERSION}).") + print(" Builds were validated against the pinned commit. If a build " + "fails unexpectedly, this is the first suspect.") + print(f" To pin: (cd {idf_path} && git checkout {PINNED_IDF_COMMIT})\n") + # The shallow `git clone --depth 1` users typically run skips submodules, # but install.{sh,bat} needs the vendored tooling under `tools/idf_tools.py` # and the `components/*/` submodules. Run an idempotent submodule init — diff --git a/scripts/check/check_specs.py b/scripts/check/check_specs.py index f4b7b104..a0fbe30b 100644 --- a/scripts/check/check_specs.py +++ b/scripts/check/check_specs.py @@ -38,6 +38,15 @@ def find_moonmodules(): has_controls = re.search(r'controls_\.add', content) if has_pure_virtual and not has_controls: continue + # Skip CRTP template bases (e.g. ParallelLedDriver): a + # `template<...> class X : public DriverBase` is shared infrastructure, + # not a registered module — its controls belong to the concrete derived + # classes, which carry the docs. The concrete subclass is `class Foo : + # public X` (no leading `template<`), so it is still picked up. + if re.search(r'template\s*<[^>]*>\s*class\s+\w+\s*:\s*public\s+\w*' + r'(MoonModule|EffectBase|DriverBase|ModifierBase|LayoutBase)', + content): + continue modules.append(h_file) return modules diff --git a/scripts/docs/screenshot_modules.py b/scripts/docs/screenshot_modules.py index c7d6a26e..dca9c511 100644 --- a/scripts/docs/screenshot_modules.py +++ b/scripts/docs/screenshot_modules.py @@ -22,15 +22,44 @@ docs/assets/screenshots/installer.png — web installer page Usage: - uv run scripts/docs/screenshot_modules.py [--host localhost:8080] [--force] - -Prerequisites: - uv run playwright install chromium # one-time - ffmpeg must be on PATH (brew install ffmpeg) - For installer: uv run scripts/run/preview_installer.py (serves on port 8000) + # one effect, raw (no modifier), good preview size, with its GIF, overwriting: + uv run scripts/docs/screenshot_modules.py --filter Ripples --no-modifier --grid 32 --gif --force + # everything, same look: + uv run scripts/docs/screenshot_modules.py --no-modifier --grid 32 --gif --force + +Preview quality: + --no-modifier removes the default MultiplyModifier so effects render RAW, not + mirror-folded. Recommended for effect previews. + --grid N resizes the grid before capture. 32 is the sweet spot: bigger + than the 16 boot default (more detail) but still dense in the + preview. NOTE: 128 looks WORSE, not better — the preview index- + downsamples to a fixed send budget, so a huge grid spreads sparse + effects (Ripples, Game of Life) to scattered dots. Use ~32. + +Prerequisites (one-time, machine-local — NOT in the repo or the venv): + 1. ffmpeg on PATH brew install ffmpeg + 2. Playwright's chromium browser: + uv run --with playwright playwright install chromium + NOTE: plain `uv run playwright …` FAILS — playwright isn't a project + dependency, it's an inline (PEP 723) dep of THIS script, so the browser + install needs `--with playwright`. On macOS the browser lands in + ~/Library/Caches/ms-playwright/ (not ~/.cache/). It survives venv rebuilds + and repo transfers — if it's "missing" it was simply never installed here. + +Running server: the script captures against whatever is on --host (default +:8080). Start ONE fresh server: cmake --build build -j && ./build/projectMM +(or via MoonDeck's PC tab). GOTCHA: a leftover binary on :8080 captures the WRONG +images silently — e.g. a `build/macos/projectMM` from a MoonDeck run still bound +to the port serves the OLD code, so a renamed/changed effect screenshots as its +previous version no matter how often you rebuild. The script now prints a STALE +SERVER warning when the running types don't match src/main.cpp; if you see it, +`pkill -f projectMM` and start exactly one server. GIFs need --gif (PNG-only +otherwise). Always eyeball the output PNG — the card title + controls should +match the effect you captured. """ import argparse +import re import shutil import subprocess import sys @@ -65,13 +94,16 @@ ("ParticlesEffect", "Layer", {}, True), ("GlowParticlesEffect", "Layer", {}, True), ("CheckerboardEffect", "Layer", {}, True), + ("RingsEffect", "Layer", {}, True), ("RipplesEffect", "Layer", {}, True), ("LavaLampEffect", "Layer", {}, True), ("SpiralEffect", "Layer", {}, True), + ("GameOfLifeEffect", "Layer", {}, True), # Modifiers — added as children of Layer - ("MirrorModifier", "Layer", {}, True), + ("MultiplyModifier", "Layer", {}, True), + ("CheckerboardModifier","Layer", {}, True), # Drivers - ("ArtNetSendDriver", "Drivers", {}, False), + ("NetworkSendDriver", "Drivers", {}, False), ("PreviewDriver", "Drivers", {}, False), ] @@ -235,6 +267,53 @@ def delete_module(host: str, id_: str) -> bool: return r.ok +def set_control(host: str, module: str, control: str, value) -> bool: + """Set a control value via the same /api/control path the UI uses.""" + r = _post(f"http://{host}/api/control", + json={"module": module, "control": control, "value": value}, timeout=5) + return r.ok + + +def prepare_pipeline(host: str, drop_modifiers: bool, grid: int | None) -> None: + """Make the capture pipeline match the requested look BEFORE adding effects. + + The default boot pipeline is Grid(16x16) + Layer(Noise + MultiplyModifier). + Two knobs improve effect previews: + - drop_modifiers: delete any Modifier children of the Layer so each effect + renders RAW, not folded/mirrored by the default MultiplyModifier. + - grid: resize the GridLayout (e.g. 128) for crisper, higher-res previews. + Both are best-effort and logged; a failure here doesn't abort the run. + """ + try: + sr = _get(f"http://{host}/api/state", timeout=5) + if not sr.ok: + return + mods = sr.json().get("modules", []) + except Exception: + return + + def walk(ms, role_wanted, type_contains): + out = [] + for m in ms: + if m.get("role") == role_wanted or type_contains in m.get("type", ""): + out.append(m) + out.extend(walk(m.get("children", []), role_wanted, type_contains)) + return out + + if drop_modifiers: + for m in walk(mods, "modifier", "Modifier"): + if delete_module(host, m.get("name", "")): + print(f" pipeline: removed modifier {m.get('name')!r} (raw effect preview)") + + if grid: + for g in walk(mods, "", "GridLayout"): + name = g.get("name", "") + ok_w = set_control(host, name, "width", grid) + ok_h = set_control(host, name, "height", grid) + if ok_w and ok_h: + print(f" pipeline: grid {name!r} -> {grid}x{grid}") + + def get_types(host: str) -> set[str]: r = _get(f"http://{host}/api/types", timeout=5) if not r.ok: @@ -244,6 +323,47 @@ def get_types(host: str) -> set[str]: return {t["name"] if isinstance(t, dict) else t for t in types} +def source_registered_types() -> set[str]: + """The module type names main.cpp registers, parsed from registerType<...>("Name", ...). + + Used to detect a STALE server binary: if the running server is missing a type + the source registers, it's an old build (or a different binary) — the cause of + a hard-to-spot bug where screenshots capture the previous version of an effect. + Returns an empty set if main.cpp can't be read (then the check is skipped). + """ + main_cpp = ROOT / "src" / "main.cpp" + try: + text = main_cpp.read_text() + except OSError: + return set() + # registerType("FooEffect", "...") — capture the quoted name. + return set(re.findall(r'registerType<[^>]+>\(\s*"([^"]+)"', text)) + + +def check_server_freshness(host: str) -> None: + """Warn loudly if the running server looks like a stale binary. + + Compares the server's registered types against what src/main.cpp registers. + The classic failure (seen 2026-06): a leftover `build/macos/projectMM` from a + MoonDeck run was still bound to :8080, so captures showed the PRE-rename effect + no matter how many times the dev binary was rebuilt. A missing type is the + tell — surface it with the fix instead of silently capturing wrong images. + """ + src = source_registered_types() + if not src: + return # couldn't parse main.cpp — skip rather than false-alarm + server = get_types(host) + missing = sorted(src - server) + if missing: + print(f" ⚠️ STALE SERVER: {host} is missing {len(missing)} type(s) the " + f"source registers: {', '.join(missing[:6])}" + + (" …" if len(missing) > 6 else "")) + print(f" The running binary is older than the current source. Most likely a") + print(f" second projectMM (e.g. build/macos/projectMM from MoonDeck) is still") + print(f" on :8080. Fix: pkill -f projectMM then rebuild + run ONE server:") + print(f" cmake --build build -j && ./build/projectMM") + + # --------------------------------------------------------------------------- # Pipeline discovery # --------------------------------------------------------------------------- @@ -489,6 +609,12 @@ def main() -> int: help="Also capture animated GIF previews for effects/modifiers") parser.add_argument("--filter", default="", help="Only capture modules whose type name contains this substring (case-insensitive)") + parser.add_argument("--no-modifier", action="store_true", + help="Remove the default MultiplyModifier from the Layer so effects " + "render RAW (not folded/mirrored) in their previews.") + parser.add_argument("--grid", type=int, default=0, metavar="N", + help="Resize the GridLayout to NxN before capturing (e.g. 128) for " + "higher-resolution previews. Default: leave the boot grid (16).") parser.add_argument("--extras-only", action="store_true", help="Skip projectMM module captures; only run the extra shots " "(MoonDeck tabs, installer). Useful for recapturing the " @@ -515,6 +641,24 @@ def main() -> int: server_types = get_types(args.host) if server_types: print(f"Server reports {len(server_types)} module types.") + # Catch a stale / wrong server binary before capturing anything against it. + check_server_freshness(args.host) + + # Catch the MODULES list drifting from registered types — a registered + # effect/modifier with no list entry is silently never captured (how + # GameOfLife/MultiplyModifier/CheckerboardModifier went imageless). + listed = {t for t, *_ in MODULES} + uncaptured = sorted( + t for t in source_registered_types() + if ("Effect" in t or "Modifier" in t) and t not in listed) + if uncaptured: + print(f" ⚠️ {len(uncaptured)} registered effect/modifier(s) are NOT in this " + f"script's MODULES list, so they get no screenshot: {', '.join(uncaptured)}") + print(f" Add them to MODULES (near the top of this file) to capture them.") + + # Optional pipeline tweaks for nicer effect previews (raw, higher-res). + if args.no_modifier or args.grid: + prepare_pipeline(args.host, args.no_modifier, args.grid or None) # Known name prefixes for orphan sweep (first 16 chars of each type name). known_prefixes = {t[:16] for t, _, _, _ in MODULES} @@ -750,6 +894,14 @@ def _sweep(modules: list) -> None: print(f"Captured : {len(captured)} PNGs, {len(gif_captured)} GIFs") print(f"Skipped : {len(skipped)}") print(f"Failed : {len(failed)}") + # GIFs are PNG-only by default — remind when an effect/modifier capture ran + # without --gif, since "0 GIFs" on an effect run usually means the flag was + # forgotten, not that nothing animates. + if not args.gif and not args.extras_only and any( + (not filt or filt in t.lower()) and want_gif + for t, _, _, want_gif in MODULES): + print("\nNote: GIFs were NOT captured — re-run with --gif to also record " + "animated previews for effects/modifiers.") if failed: print("\nFailed:") for name, reason in failed: diff --git a/scripts/moondeck.py b/scripts/moondeck.py index 0f08c12f..0b179d86 100644 --- a/scripts/moondeck.py +++ b/scripts/moondeck.py @@ -1045,6 +1045,16 @@ def _handle_run(self, script_id: str, params: dict): cmd.extend(["--name", params["scenario"]]) if script_def.get("needs_module") and params.get("module"): cmd.extend(["--module", params["module"]]) + # pass_board: forward the board picked in the UI's provisioning + # dropdown (state.provisionBoard) so improv_provision.py injects that + # board's TX-power cap BEFORE provisioning (the LOLIN brown-out fix). + # No firmware-deduce fallback: the only pass_board script + # (improv_provision) doesn't declare needs_firmware, so params never + # carries a firmware to deduce from — the dropdown is the sole source. + if script_def.get("pass_board"): + board = params.get("board") + if board: + cmd.extend(["--board", board]) if params.get("host"): cmd.extend(["--host", params["host"]]) for flag in script_def.get("flags", []): diff --git a/scripts/moondeck_config.json b/scripts/moondeck_config.json index 05f6411f..d0bfbd24 100644 --- a/scripts/moondeck_config.json +++ b/scripts/moondeck_config.json @@ -121,6 +121,22 @@ "needs_module": true, "needs_scenario": true }, + { + "id": "network_live_test", + "tab": "live", + "group": "scenario", + "label": "Network Live Test", + "help": "run_network_live", + "script": "scenario/run_network_live.py" + }, + { + "id": "network_roundtrip", + "tab": "live", + "group": "scenario", + "label": "Network Round-trip", + "help": "run_network_roundtrip", + "script": "scenario/run_network_roundtrip.py" + }, { "id": "setup_esp_idf", "tab": "esp32", @@ -184,7 +200,8 @@ "label": "Improv WiFi", "help": "improv_provision", "script": "build/improv_provision.py", - "needs_port": true + "needs_port": true, + "pass_board": true }, { "id": "improv_probe", diff --git a/scripts/moondeck_ui/app.js b/scripts/moondeck_ui/app.js index fce590cf..bd0ba865 100644 --- a/scripts/moondeck_ui/app.js +++ b/scripts/moondeck_ui/app.js @@ -77,6 +77,7 @@ async function init() { } renderFirmwareSelect(); + renderBoardSelect(); renderScripts(); renderNetworkBar(); try { renderDevices(); } catch (e) { console.error("renderDevices:", e); } @@ -506,6 +507,7 @@ async function runScriptOnce(script, btn, extraParams) { if (script.needs_port) params.port = (getActiveNetwork()?.port) || ""; if (script.needs_scenario) params.scenario = state.scenario; if (script.needs_module) params.module = state.module; + if (script.pass_board) params.board = state.provisionBoard || ""; for (const flag of (script.flags || [])) { const stateKey = `flag_${script.id}_${flag.id}`; params[`flag_${flag.id}`] = stateKey in state ? state[stateKey] : flag.default; @@ -583,6 +585,35 @@ async function runScriptOnce(script, btn, extraParams) { // ESP32 controls // --------------------------------------------------------------------------- +// Board picker for provisioning scripts (pass_board): boards.json entries +// whose `firmwares` include the selected firmware. "(any board)" = no +// injection. Distinct state key from the LEGACY `state.board` (which meant +// firmware — see the migration in init) and from per-device boards. +function renderBoardSelect() { + const select = document.getElementById("board-select"); + if (!select) return; + select.innerHTML = ""; + const candidates = boards.filter(b => (b.firmwares || []).includes(state.firmware)); + const options = [["", "(any board)"], ...candidates.map(b => [b.name, b.name])]; + if (state.provisionBoard && !options.some(([v]) => v === state.provisionBoard)) { + state.provisionBoard = ""; // firmware changed; stale pick no longer applies + } + for (const [val, lbl] of options) { + const opt = document.createElement("option"); + opt.value = val; + opt.textContent = lbl; + if (val === (state.provisionBoard || "")) opt.selected = true; + select.appendChild(opt); + } + // onchange (assigned, not addEventListener) so a re-render replaces the + // handler instead of stacking another — addEventListener here would fire + // saveState() once per past render. + select.onchange = async () => { + state.provisionBoard = select.value; + await saveState(); + }; +} + function renderFirmwareSelect() { const select = document.getElementById("firmware-select"); select.innerHTML = ""; @@ -600,6 +631,7 @@ function renderFirmwareSelect() { select.addEventListener("change", async () => { state.firmware = select.value; await saveState(); + renderBoardSelect(); // board candidates follow the firmware }); } diff --git a/scripts/moondeck_ui/index.html b/scripts/moondeck_ui/index.html index 8f4d8dd6..7148209d 100644 --- a/scripts/moondeck_ui/index.html +++ b/scripts/moondeck_ui/index.html @@ -54,6 +54,7 @@

MoonDeck

+
diff --git a/scripts/scenario/_net_probe.py b/scripts/scenario/_net_probe.py new file mode 100644 index 00000000..553a8441 --- /dev/null +++ b/scripts/scenario/_net_probe.py @@ -0,0 +1,102 @@ +"""Shared lights-over-UDP test helpers — the surface both network live tests +build on (the matrix test run_network_live.py and the latency probe +run_network_roundtrip.py). + +Kept in its own module (like _preview_ws.py) so the shared bits — protocol +ports, the three packet builders, and the MoonDeck device set — have one clear +home instead of living inside one of the two consumers. Nothing here is +specific to either test; the matrix-only colour-correction and Board +orchestration stay in run_network_live.py. + +The packet builders mirror the firmware encoders byte for byte — cross-language +duplication is unavoidable here; keep each in sync with its `.h`: + build_artdmx ↔ src/light/ArtNetPacket.h::buildArtDmxPacket + build_e131 ↔ src/light/E131Packet.h::buildE131Packet + build_ddp ↔ src/light/DdpPacket.h::buildDdpPacket +""" + +import json +import sys +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +MOONDECK_STATE = ROOT / "scripts" / "moondeck.json" + +ARTNET_PORT = 6454 +E131_PORT = 5568 +DDP_PORT = 4048 +CHANNELS_PER_UNIVERSE = 510 +PROTOCOLS = ["ArtNet", "E1.31", "DDP"] # mirrors NetworkSendDriver.kProtocolOptions + + +def build_artdmx(universe: int, sequence: int, data: bytes) -> bytes: + pkt = bytearray(b"Art-Net\0") + pkt += bytes([0x00, 0x50]) # OpDmx, little-endian + pkt += bytes([0x00, 0x0E]) # protocol 14, big-endian + pkt += bytes([sequence & 0xFF, 0]) # sequence, physical + pkt += bytes([universe & 0xFF, (universe >> 8) & 0xFF]) # universe LE + pkt += bytes([(len(data) >> 8) & 0xFF, len(data) & 0xFF]) # length BE + pkt += data + return bytes(pkt) + + +def build_e131(universe: int, sequence: int, data: bytes) -> bytes: + total = 126 + len(data) + pkt = bytearray(126) + pkt[0:2] = (0x0010).to_bytes(2, "big") # preamble size + pkt[4:16] = b"ASC-E1.17\0\0\0" + pkt[16:18] = (0x7000 | (total - 16)).to_bytes(2, "big") + pkt[21] = 0x04 # root vector + pkt[22:38] = b"run_network_live" # CID (any stable 16 bytes) + pkt[38:40] = (0x7000 | (total - 38)).to_bytes(2, "big") + pkt[43] = 0x02 # framing vector + pkt[44:53] = b"projectMM" # source name (NUL-padded) + pkt[108] = 100 # priority + pkt[111] = sequence & 0xFF + pkt[113:115] = universe.to_bytes(2, "big") + pkt[115:117] = (0x7000 | (total - 115)).to_bytes(2, "big") + pkt[117] = 0x02 # DMP vector + pkt[118] = 0xA1 + pkt[122] = 0x01 # address increment + pkt[123:125] = (1 + len(data)).to_bytes(2, "big") # property count + return bytes(pkt) + data + + +def build_ddp(offset: int, push: bool, data: bytes) -> bytes: + pkt = bytearray(10) + pkt[0] = 0x40 | (0x01 if push else 0x00) + pkt[2] = 0x01 # RGB + pkt[3] = 0x01 # default display + pkt[4:8] = offset.to_bytes(4, "big") + pkt[8:10] = len(data).to_bytes(2, "big") + return bytes(pkt) + data + + +def load_selected_devices(): + """The devices the user CHECKED in MoonDeck's Live tab (the `selected` flag, + persisted to moondeck.json), of the active network, re-probed for + reachability (a stale flag must not hang the run on a 15 s mutating-call + timeout). The checkbox is the run set — it's what the UI implies and what + app.js filters Live-tab runs by. A device with no `selected` key (older + state) counts as selected, so a freshly-discovered list still runs.""" + if not MOONDECK_STATE.exists(): + print(f"FAIL {MOONDECK_STATE} not found — run MoonDeck once to discover devices") + sys.exit(2) + state = json.loads(MOONDECK_STATE.read_text()) + nets = state.get("networks", []) + net = next((n for n in nets if n.get("name") == state.get("active_network")), + nets[0] if nets else None) + if not net: + print("FAIL moondeck.json has no networks") + sys.exit(2) + devices = [] + for dev in net.get("devices", []): + if not dev.get("selected", True): # unchecked → skip; missing key → run + continue + try: # quick reachability probe, far shorter than Client's mutate timeout + with urllib.request.urlopen(f"http://{dev['ip']}/api/state", timeout=3): + devices.append(dev) + except OSError: + print(f" WARN {dev.get('deviceName', dev['ip'])} checked but unreachable — skipped") + return devices diff --git a/scripts/scenario/_preview_ws.py b/scripts/scenario/_preview_ws.py new file mode 100644 index 00000000..c01a6bcd --- /dev/null +++ b/scripts/scenario/_preview_ws.py @@ -0,0 +1,120 @@ +"""Minimal RFC 6455 WebSocket client for the device's /ws preview stream. + +Stdlib-only (socket/base64/os/time) — the live test scripts must run anywhere +uv runs, with no third-party deps. Sibling-private helper like _observed.py. + +The device pushes two things on /ws: full-state JSON as text frames (~1 Hz) and +PreviewDriver binary frames — 0x03 coordinate tables and 0x02 RGB frames +(`[0x02][count u16 LE][stride u16 LE][rgb × count]`, see +src/light/drivers/PreviewDriver.h). This reader skips everything except 0x02. + +Two simplifications the firmware guarantees (HttpServerModule.cpp): +- frames are never fragmented (broadcastBinary always sends FIN frames), so a + frame is fully described by its header; +- payloads stay far below 64 KiB, so the 127 (u64) length form never occurs. +We never send a data frame (server pushes unprompted), so no client-side +masking is needed; closing the TCP socket is how a vanishing browser behaves +and the server reaps it the same way. +""" + +import base64 +import os +import socket +import time + + +class PreviewSocket: + """One /ws connection. `host` is the device's HTTP address ("ip[:port]").""" + + def __init__(self, host: str, timeout_s: float = 5.0): + h, _, p = host.partition(":") + self.sock = socket.create_connection((h, int(p or 80)), timeout=timeout_s) + key = base64.b64encode(os.urandom(16)).decode() + self.sock.sendall( + (f"GET /ws HTTP/1.1\r\nHost: {host}\r\n" + f"Upgrade: websocket\r\nConnection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode()) + raw = b"" + while b"\r\n\r\n" not in raw: + chunk = self.sock.recv(1024) + if not chunk: + raise ConnectionError("WS handshake: connection closed") + raw += chunk + head, _, rest = raw.partition(b"\r\n\r\n") + if b" 101" not in head.split(b"\r\n", 1)[0]: + self.sock.close() + raise ConnectionError( + "WS handshake refused — the device serves at most 4 preview " + "clients; close open preview browser tabs and retry. Got: " + + head.split(b"\r\n", 1)[0].decode(errors="replace")) + # Bytes after the handshake headers are already the first frame(s). + self._buf = rest + # Status 101 is proof enough; skipping the Sec-WebSocket-Accept check + # saves the SHA-1 dance against our own firmware. + + def close(self): + try: + self.sock.close() + except OSError: + pass + + def _read_exact(self, n: int, deadline: float) -> bytes: + while len(self._buf) < n: + self.sock.settimeout(max(0.05, deadline - time.monotonic())) + chunk = self.sock.recv(4096) + if not chunk: + raise ConnectionError("WS stream closed by device") + self._buf += chunk + out, self._buf = self._buf[:n], self._buf[n:] + return out + + def read_frame(self, deadline: float): + """Return (opcode, payload) for the next frame. Raises on close/timeout.""" + b0, b1 = self._read_exact(2, deadline) + opcode = b0 & 0x0F + length = b1 & 0x7F # server→client frames are unmasked (mask bit 0) + if length == 126: + ext = self._read_exact(2, deadline) + length = (ext[0] << 8) | ext[1] + payload = self._read_exact(length, deadline) + if opcode == 0x8: + raise ConnectionError("WS close frame from device") + return opcode, payload + + +def wait_for_solid(host: str, rgb, tolerance: int = 0, min_match_pct: float = 100.0, + timeout_s: float = 10.0): + """Wait for a 0x02 preview frame where ≥min_match_pct of the streamed points + equal `rgb` (per-channel ±tolerance). Returns (ok, best_pct, points, detail); + on failure `detail` is one sample mismatching point for the error message.""" + deadline = time.monotonic() + timeout_s + best_pct, points, detail = 0.0, 0, "" + ws = PreviewSocket(host) + try: + while time.monotonic() < deadline: + try: + opcode, payload = ws.read_frame(deadline) + except TimeoutError: # socket.timeout is an alias since Python 3.10 + break + if opcode != 0x2 or not payload or payload[0] != 0x02: + continue # text/state frame or 0x03 coordinate table + count = payload[1] | (payload[2] << 8) + triples = payload[5:5 + count * 3] + if count == 0 or len(triples) < count * 3: + continue + matched = 0 + sample = "" + for i in range(count): + p = triples[i * 3:i * 3 + 3] + if all(abs(p[c] - rgb[c]) <= tolerance for c in range(3)): + matched += 1 + elif not sample: + sample = f"point {i} = ({p[0]},{p[1]},{p[2]})" + pct = matched * 100.0 / count + if pct > best_pct: + best_pct, points, detail = pct, count, sample + if pct >= min_match_pct: + return True, pct, count, "" + return False, best_pct, points, detail + finally: + ws.close() diff --git a/scripts/scenario/run_network_live.py b/scripts/scenario/run_network_live.py new file mode 100644 index 00000000..fcbba1c2 --- /dev/null +++ b/scripts/scenario/run_network_live.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +"""Live lights-over-UDP matrix test across every online board in moondeck.json. + +Proves the multi-protocol network path (ArtNet, E1.31/sACN, DDP) end-to-end on +real firmware. Devices come from scripts/moondeck.json (the MoonDeck device +list, active network, online only). Each round one device is the SENDER and +every other device LISTENS: + + 1. The PC seeds the sender three times — once per protocol, each with its own + colour — to the sender's protocol ports (6454/5568/4048); the + NetworkReceiveEffect (added to each device's Layer for the run) listens on + all three at once. The sender's /ws preview stream must show each colour — + proves PC → device receive per protocol. + 2. The sender's own NetworkSendDriver is pointed at each listener in turn, + with its protocol control cycled round-robin so all three send paths get + exercised across a matrix run; the listener's preview must show the + sender's CORRECTED colour (the send driver applies brightness + channel + order) — proves device → device over real firmware send + receive. + +With one online device only step 1 runs (the matrix needs ≥2 boards). All +mutated state (grid size, NetworkSend ip/protocol/enabled, the added effects) +is restored in a finally block. Exit codes follow improv_smoke_test.py: 0 = +all legs passed, 1 = a leg failed, 2 = environment problem (no devices, +moondeck.json missing). + +Run: uv run scripts/scenario/run_network_live.py [--device NAME] [--host IP] +""" + +import argparse +import json +import sys +import time +import socket +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from run_live_scenario import Client, _control_value # shared HTTP wrapper # noqa: E402 +import _preview_ws # noqa: E402 +# Shared lights-over-UDP surface (ports, packet builders, device set) — see +# _net_probe.py; the matrix-only colour-correction/Board logic stays below. +from _net_probe import ( # noqa: E402 + ARTNET_PORT, E131_PORT, DDP_PORT, CHANNELS_PER_UNIVERSE, PROTOCOLS, + MOONDECK_STATE, build_artdmx, build_e131, build_ddp, load_selected_devices, +) + +# Round colours: channel values far apart so they stay distinct after the +# sender's brightness scale (default 20/255 → e.g. (255,128,0) → (20,10,0)), +# and distinct per round so a stale frame from an earlier round can't pass. +ROUND_COLOURS = [(255, 128, 0), (0, 255, 128), (128, 0, 255), + (255, 0, 128), (128, 255, 0), (0, 128, 255)] + +# Mirrors src/light/drivers/Correction.h (briLut scale + order[] reorder) — a +# listener sees the sender's corrected bytes, so the expected colour replicates +# that transform. 3-channel presets only; RGBW senders emit 4 bytes/light which +# misaligns a 3-channel listener buffer, so those legs are skipped. Keep in sync. +PRESET_ORDER = {"RGB": (0, 1, 2), "RBG": (0, 2, 1), "GRB": (1, 0, 2), + "GBR": (1, 2, 0), "BRG": (2, 0, 1), "BGR": (2, 1, 0)} +PRESET_NAMES = ["RGB", "RBG", "GRB", "GBR", "BRG", "BGR", "RGBW", "GRBW"] + + +def corrected(rgb, brightness, preset): + scaled = [(v * int(brightness)) // 255 for v in rgb] + order = PRESET_ORDER[preset] + return tuple(scaled[order[i]] for i in range(3)) + + +# build_artdmx / build_e131 / build_ddp now live in _net_probe.py (imported +# above) — shared with the latency probe. + + +def send_solid(host: str, rgb, protocol: str = "ArtNet", universes: int = 2, + repeats: int = 10, pace_ms: int = 50): + """Send `repeats` full frames of solid colour to the device via the given + protocol (the receiver autodetects on all three ports). Repeats absorb WiFi + power-save first-packet loss; the receiver's hold-last-frame staging means + one arrival suffices.""" + ip = host.partition(":")[0] + payload = bytes(rgb) * (CHANNELS_PER_UNIVERSE // 3) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + seq = 0 + for _ in range(repeats): + if protocol == "DDP": + # One byte-addressed chunk per universe-sized slice keeps the + # frame shape identical across protocols. + for u in range(universes): + last = u == universes - 1 + sock.sendto(build_ddp(u * CHANNELS_PER_UNIVERSE, last, payload), + (ip, DDP_PORT)) + elif protocol == "E1.31": + for u in range(universes): + sock.sendto(build_e131(u, seq, payload), (ip, E131_PORT)) + else: + for u in range(universes): + sock.sendto(build_artdmx(u, seq, payload), (ip, ARTNET_PORT)) + seq = (seq + 1) & 0xFF + time.sleep(pace_ms / 1000.0) + finally: + sock.close() + + +# load_selected_devices now lives in _net_probe.py (imported above) — shared +# with the latency probe. + + +def find_module(state: dict, name: str): + def walk(mod): + if mod.get("name") == name: + return mod + for child in mod.get("children", []): + found = walk(child) + if found: + return found + return None + for mod in state.get("modules", []): + found = walk(mod) + if found: + return found + return None + + +# --- per-device setup / restore ---------------------------------------------- + +class Board: + """One device under test: HTTP client + the original state we mutate.""" + + def __init__(self, dev: dict): + self.name = dev.get("deviceName") or dev["ip"] + self.host = dev["ip"] + self.client = Client(self.host) + state = self.client.get("/api/state") + grid = find_module(state, "Grid") or {} + drivers = find_module(state, "Drivers") or {} + artnet = find_module(state, "NetworkSend") or {} + self.orig_w = _control_value(grid, "width") + self.orig_h = _control_value(grid, "height") + self.orig_ip = _control_value(artnet, "ip") + self.orig_protocol = _control_value(artnet, "protocol") + # The user may have NetworkSend disabled (e.g. while testing LED output); + # relay legs need it on, so remember the original state to restore. + self.artnet_enabled = bool(artnet.get("enabled", False)) + self.brightness = _control_value(drivers, "brightness") or 0 + preset_idx = _control_value(drivers, "lightPreset") or 0 + self.preset = PRESET_NAMES[int(preset_idx)] if int(preset_idx) < len(PRESET_NAMES) else "RGB" + self.added_receiver = False + self.ip_changed = False + self.enable_changed = False + self.protocol_changed = False + + def set_control(self, module: str, control: str, value): + self.client.post("/api/control", + {"module": module, "control": control, "value": value}) + + def setup(self): + # Same grid everywhere: a solid frame must fill every listener's buffer + # (16×16 → 256 lights → 2 universes, preview stride 1). + self.set_control("Grid", "width", 16) + self.set_control("Grid", "height", 16) + self.client.post("/api/modules", {"type": "NetworkReceiveEffect", + "id": "NetworkReceive", "parent_id": "Layer"}) + self.added_receiver = True + + def restore(self): + if self.added_receiver: + try: + self.client.delete("/api/modules/NetworkReceive") + except Exception as e: + print(f" WARN {self.name}: could not remove NetworkReceive: {e}") + if self.ip_changed and self.orig_ip is not None: + try: + self.set_control("NetworkSend", "ip", self.orig_ip) + except Exception as e: + print(f" WARN {self.name}: could not restore NetworkSend.ip: {e}") + if self.enable_changed: + try: + self.set_control("NetworkSend", "enabled", self.artnet_enabled) + except Exception as e: + print(f" WARN {self.name}: could not restore NetworkSend.enabled: {e}") + if self.protocol_changed and self.orig_protocol is not None: + try: + self.set_control("NetworkSend", "protocol", self.orig_protocol) + except Exception as e: + print(f" WARN {self.name}: could not restore NetworkSend.protocol: {e}") + for key, val in (("width", self.orig_w), ("height", self.orig_h)): + if val is not None: + try: + self.set_control("Grid", key, val) + except Exception as e: + print(f" WARN {self.name}: could not restore Grid.{key}: {e}") + + +# --- the matrix ---------------------------------------------------------------- + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--device", help="only run rounds where this deviceName is the sender") + ap.add_argument("--host", help="only run rounds where this host is the sender " + "(MoonDeck forwards the selected device here)") + ap.add_argument("--tolerance", type=int, default=0, + help="per-channel colour tolerance (default 0 — preview is byte-exact)") + ap.add_argument("--timeout", type=float, default=10.0, + help="seconds to wait for a matching preview frame per leg") + ap.add_argument("--packets", type=int, default=10, help="PC seed frame repeats") + ap.add_argument("--pace-ms", type=int, default=50, help="pause between seed frames") + ap.add_argument("--settle", type=float, default=2.0, + help="seconds after a mutating call before asserting") + args = ap.parse_args() + + devices = load_selected_devices() + if not devices: + print("FAIL no selected+reachable devices in moondeck.json's active " + "network — check device boxes in the Live tab") + return 2 + print(f"devices: {len(devices)} selected — " + + ", ".join(f"{d.get('deviceName', '?')} ({d['ip']})" for d in devices), flush=True) + if len(devices) == 1: + print("note: only one device selected — running the PC→device leg; " + "the device↔device matrix needs ≥2 boards", flush=True) + + boards = [Board(d) for d in devices] + passed, failed, skipped = 0, 0, 0 + relay_count = 0 # global counter so relay protocols cycle across ALL legs + # (a per-round formula repeats the same protocol when the + # board count and protocol count share a factor) + try: + for b in boards: + b.setup() + time.sleep(args.settle) # one settle for the buildStates above + + for k, sender in enumerate(boards): + if args.device and sender.name != args.device: + continue + if args.host and sender.host != args.host: + continue + colour = ROUND_COLOURS[k % len(ROUND_COLOURS)] + print(f"== round {k + 1}/{len(boards)}: sender {sender.name}, " + f"colour {colour}", flush=True) + + # Leg 1 — the seed sweep: the PC seeds the sender once per protocol, + # each with a rotated colour (a stale frame from the previous + # protocol can't false-pass); the receiver autodetects all three. + # The sender's preview shows the RAW colour (uncorrected buffer). + seeded_colour = None + for pi, proto in enumerate(PROTOCOLS): + proto_colour = colour[pi:] + colour[:pi] + send_solid(sender.host, proto_colour, protocol=proto, + repeats=args.packets, pace_ms=args.pace_ms) + ok, pct, pts, detail = _preview_ws.wait_for_solid( + sender.host, proto_colour, args.tolerance, 100.0, args.timeout) + if ok: + print(f"PASS pc → {sender.name} [{proto}] (preview solid, {pts} points)", + flush=True) + passed += 1 + seeded_colour = proto_colour + else: + print(f"FAIL pc → {sender.name} [{proto}] (best {pct:.0f}% of {pts} points" + f"{', ' + detail if detail else ''})" + " — desktop listeners: check the OS firewall allows UDP 6454/5568/4048", + flush=True) + failed += 1 + if seeded_colour is None: + continue # without a seeded sender the relay legs can't mean anything + colour = seeded_colour # the sender's buffer now holds the last seeded colour + + # Legs 2..N — sender relays to each listener via its own + # NetworkSendDriver, cycling the protocol control round-robin so a + # full matrix run exercises all three firmware send paths; + # listeners see the sender's CORRECTED colour. + for listener in boards: + if listener is sender: + continue + if self_skip := _relay_skip_reason(sender): + print(f"SKIP {sender.name} → {listener.name} ({self_skip})", flush=True) + skipped += 1 + continue + relay_proto = relay_count % len(PROTOCOLS) + relay_count += 1 + expected = corrected(colour, sender.brightness, sender.preset) + if not sender.artnet_enabled and not sender.enable_changed: + sender.set_control("NetworkSend", "enabled", True) + sender.enable_changed = True + sender.set_control("NetworkSend", "protocol", relay_proto) + sender.protocol_changed = True + sender.set_control("NetworkSend", "ip", listener.host.partition(":")[0]) + sender.ip_changed = True + ok, pct, pts, detail = _preview_ws.wait_for_solid( + listener.host, expected, args.tolerance, 100.0, args.timeout) + if ok: + print(f"PASS {sender.name} → {listener.name} [{PROTOCOLS[relay_proto]}] " + f"(expected {expected} after sender correction, {pts} points)", flush=True) + passed += 1 + else: + print(f"FAIL {sender.name} → {listener.name} [{PROTOCOLS[relay_proto]}] " + f"(expected {expected}, best {pct:.0f}% of {pts} points" + f"{', ' + detail if detail else ''})", flush=True) + failed += 1 + if sender.ip_changed and sender.orig_ip is not None: + sender.set_control("NetworkSend", "ip", sender.orig_ip) + sender.ip_changed = False + if sender.protocol_changed and sender.orig_protocol is not None: + sender.set_control("NetworkSend", "protocol", sender.orig_protocol) + sender.protocol_changed = False + finally: + for b in boards: + b.restore() + + print(f"SUMMARY: {passed} passed, {failed} failed, {skipped} skipped", flush=True) + return 1 if failed else 0 + + +def _relay_skip_reason(sender: "Board"): + """A relay leg is meaningless when the sender's correction destroys the + signal: RGBW presets emit 4 bytes/light (misaligns a 3-channel listener), + and brightness 0 corrects every colour to black — black also matches a + listener that received NOTHING (staging zero-fill), a guaranteed false pass.""" + if sender.preset not in PRESET_ORDER: + return f"sender preset {sender.preset} is 4-channel — relay assert supports 3-channel presets" + if all(c == 0 for c in corrected((255, 255, 255), sender.brightness, "RGB")): + return "sender Drivers.brightness too low — corrected colour is black (raise brightness)" + return None + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/scenario/run_network_roundtrip.py b/scripts/scenario/run_network_roundtrip.py new file mode 100644 index 00000000..9503983f --- /dev/null +++ b/scripts/scenario/run_network_roundtrip.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Measure PC→device→PC round-trip latency per protocol on the checked boards. + +The simplest honest latency probe: the PC sends one solid-colour frame to a +device, then times how long until that colour shows up in the device's preview +WebSocket stream. The path is PC → device's NetworkReceiveEffect (writes the +layer buffer) → PreviewDriver (broadcasts the buffer over /ws) → PC. It sweeps +all three protocols — ArtNet, E1.31, DDP — per device (the receiver autodetects +each on its own port, so no reconfig between them), and prints a per-device +median-per-protocol comparison. Runs against every device CHECKED in MoonDeck's +Live tab (the `selected` flag); `--host` overrides to a single explicit device. +Works with as few as one board reachable. + +Deliberately minimal. The measured time includes the PreviewDriver's own +rate-limit (default 24 fps ≈ up to 42 ms of quantisation), so treat the number +as "state-change visible within" latency, not wire latency — raise the device's +Preview fps to tighten it. Across repeats the spread (min/median/max) is the +useful signal for the hiccups/latency symptom. + +Extend later (hooks left intentionally simple): per-frame sequence matching, +the device→device forwarding chain, jitter/drop histograms. This version sweeps +the three protocols with one probe colour, N repeats each. + +Exit codes follow the other live scripts: 0 = measured ok, 1 = no frame came +back (a real failure), 2 = environment problem (no device, moondeck.json +missing). + +Run: uv run scripts/scenario/run_network_roundtrip.py [--host IP] [--repeats N] +""" + +import argparse +import statistics +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from run_live_scenario import Client # shared HTTP wrapper # noqa: E402 +from _net_probe import ( # shared lights-over-UDP surface # noqa: E402 + ARTNET_PORT, E131_PORT, DDP_PORT, CHANNELS_PER_UNIVERSE, + build_artdmx, build_e131, build_ddp, load_selected_devices, +) +import socket # noqa: E402 +import _preview_ws # noqa: E402 + +# A colour the idle effects are unlikely to paint by themselves, so a preview +# match means OUR frame arrived, not a coincidence. Distinct on all 3 channels. +PROBE_RGB = (0x11, 0x22, 0x33) + +# The three industry protocols, each on its own port — the receiver autodetects +# on all of them at once. Order is the comparison order in the output. +PROTOCOLS = ("ArtNet", "E1.31", "DDP") + + +def seed_once(ip: str, rgb, universes: int, seq: int, protocol: str = "ArtNet"): + """Send one full frame of `rgb` across the universes via `protocol` + (fire-and-forget). Builders + ports mirror run_network_live's send_solid.""" + payload = bytes(rgb) * (CHANNELS_PER_UNIVERSE // 3) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + for u in range(universes): + if protocol == "DDP": + last = u == universes - 1 # push flag on the final chunk + sock.sendto(build_ddp(u * CHANNELS_PER_UNIVERSE, last, payload), + (ip, DDP_PORT)) + elif protocol == "E1.31": + sock.sendto(build_e131(u, seq, payload), (ip, E131_PORT)) + else: + sock.sendto(build_artdmx(u, seq, payload), (ip, ARTNET_PORT)) + finally: + sock.close() + + +def measure_roundtrip(host: str, repeats: int, timeout_s: float, protocol: str): + """Send the probe colour `repeats` times over `protocol`; time + send→preview-arrival each time. Returns the list of latencies in + milliseconds (one per successful repeat).""" + ip = host.partition(":")[0] + # 16×16 grid → 256 lights → 2 universes, the same shape the matrix test uses. + universes = 2 + latencies = [] + for i in range(repeats): + # Send the probe, then immediately start waiting for it in the preview. + # The preview holds the last frame, so arriving slightly after the send + # is fine — we time from just before the send. + t0 = time.monotonic() + seed_once(ip, PROBE_RGB, universes, seq=i & 0xFF, protocol=protocol) + # 60% match = "our frame is clearly here". A latency probe only needs to + # confirm arrival, not full coverage: the 2-universe probe payload fills + # most of the grid but not always 100% (the universe→buffer mapping and + # grid size leave a deterministic tail of pixels the probe doesn't cover + # — ~75% on a 16×16 grid here; full-coverage correctness is the matrix + # test's job, not this one's). The preview WebSocket can also drop a + # connection transiently (device busy mid-tick) — that's one bad probe, + # not a reason to abort the whole device, so catch it and record a miss. + try: + ok, best_pct, points, detail = _preview_ws.wait_for_solid( + host, PROBE_RGB, tolerance=0, min_match_pct=60.0, timeout_s=timeout_s) + except (ConnectionError, OSError) as e: + ok, best_pct, points, detail = False, 0.0, 0, f"WS error: {e}" + dt_ms = (time.monotonic() - t0) * 1000.0 + if ok: + latencies.append(dt_ms) + print(f" repeat {i + 1}/{repeats}: {dt_ms:6.1f} ms " + f"({points} pts, {best_pct:.0f}% match)", flush=True) + else: + print(f" repeat {i + 1}/{repeats}: NO FRAME (best {best_pct:.0f}% " + f"of {points} pts; {detail})", flush=True) + # Clear the probe colour between repeats so the next match is a fresh + # arrival, not the held last frame. Send black on the same protocol; + # don't time it. + seed_once(ip, (0, 0, 0), universes, seq=(i + 128) & 0xFF, protocol=protocol) + time.sleep(0.3) + return latencies + + +def _grid_value(client: Client, axis: str): + """Read the device's current Grid., or None if it can't be read — so + the caller restores only what it actually captured (restoring a guessed + default could change a grid we never measured).""" + try: + for m in client.get("/api/state").get("modules", []): + for stack in ([m], m.get("children", []) or []): + for mod in stack: + if mod.get("name") == "Grid": + for c in mod.get("controls", []): + if c.get("name") == axis and c.get("value") is not None: + return int(c["value"]) + except Exception: + pass + return None + + +def run_one(host: str, repeats: int, timeout_s: float) -> bool: + """Measure one device across all three protocols; restore its grid + afterwards. Returns True if any protocol returned a measurement.""" + client = Client(host) + added = False + # Capture the grid BEFORE any mutation, so the finally block can restore it + # whether or not the receiver add succeeds (the grid POSTs run first, so the + # grid can be left changed even if the add fails). None = couldn't read it → + # don't restore that axis (better than forcing a guessed value). + orig_w = _grid_value(client, "width") + orig_h = _grid_value(client, "height") + try: + # 16×16 → 256 lights → 2 universes, the shape the matrix test uses. + client.post("/api/control", {"module": "Grid", "control": "width", "value": 16}) + client.post("/api/control", {"module": "Grid", "control": "height", "value": 16}) + client.post("/api/modules", {"type": "NetworkReceiveEffect", + "id": "NetworkReceive", "parent_id": "Layer"}) + added = True + time.sleep(2.0) # buildState settle + + # Sweep all three protocols so they can be compared head to head — the + # receiver autodetects each on its own port, so no device reconfig + # between protocols. Collect each protocol's median for the summary. + any_ok = False + summary = [] + for proto in PROTOCOLS: + print(f" {proto}: PC→device→PC round-trip ({repeats} probes)…", flush=True) + latencies = measure_roundtrip(host, repeats, timeout_s, proto) + if not latencies: + print(f" no {proto} frame came back", flush=True) + summary.append((proto, None)) + continue + any_ok = True + latencies.sort() # for the min/max below + median = statistics.median(latencies) + print(f" {proto}: min {latencies[0]:.1f} / median {median:.1f} / " + f"max {latencies[-1]:.1f} ms over {len(latencies)}/{repeats}", flush=True) + summary.append((proto, (latencies[0], median, latencies[-1], len(latencies)))) + # Per-device comparison line across the three protocols. + parts = [] + for proto, stats in summary: + parts.append(f"{proto} {stats[1]:.0f}ms" if stats else f"{proto} —") + print(f" comparison (median): {' '.join(parts)} " + f"(incl. Preview fps quantisation — raise Preview fps to tighten)", flush=True) + if not any_ok: + print(" FAIL no protocol returned a frame — receive or preview path down") + return any_ok + finally: + # Remove the temporary receiver only if it was added; restore the grid + # regardless (the grid POSTs run before the add, so it can be changed + # even when the add failed). Skip an axis we couldn't read (None). + if added: + try: + client.delete("/api/modules/NetworkReceive") + except Exception: + pass + for axis, val in (("width", orig_w), ("height", orig_h)): + if val is None: + continue + try: + client.post("/api/control", + {"module": "Grid", "control": axis, "value": val}) + except Exception: + print(f" WARN could not restore Grid {axis} to {val}", flush=True) + + +def main() -> int: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--host", help="run only this device host[:port]; default = " + "every device checked in MoonDeck's Live tab") + ap.add_argument("--repeats", type=int, default=10, help="probes per protocol per device") + ap.add_argument("--timeout", type=float, default=5.0, + help="seconds to wait for the probe colour per repeat") + args = ap.parse_args() + + if args.host: + hosts = [(args.host, args.host)] + else: + devices = load_selected_devices() + if not devices: + print("FAIL no selected+reachable devices — check device boxes in the Live tab") + return 2 + hosts = [(d["ip"], d.get("deviceName", "?")) for d in devices] + print(f"devices: {len(hosts)} selected — " + + ", ".join(f"{n} ({h})" for h, n in hosts), flush=True) + + measured = 0 + for host, name in hosts: + print(f"\n--- {name} ({host}) ---", flush=True) + if run_one(host, args.repeats, args.timeout): + measured += 1 + + if measured == 0: + print("\nFAIL no device returned a measurement") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/core/AudioBands.h b/src/core/AudioBands.h new file mode 100644 index 00000000..4b51e83d --- /dev/null +++ b/src/core/AudioBands.h @@ -0,0 +1,113 @@ +#pragma once + +#include "core/AudioFrame.h" +#include "core/AudioLevel.h" // magToByte — the shared log/dB mapping + +#include // cosf/powf — band math is inherently float (so is the + // audioFft seam it feeds); the recognisable DSP choice. +#include +#include + +namespace mm { + +// Frequency analysis for one block of mic samples — the FFT *post-processing*, +// pure domain math with no platform header (the FFT kernel itself is the one +// seam, platform.h audioFft). Host-tested: feed a synthesized sine through +// applyWindow -> audioFft (the desktop naive DFT) -> magnitudesToBands and assert +// the dominant band + peak frequency, all in CI without an ESP32. +// +// Textbook real-signal spectrum analysis, nothing exotic: +// - applyWindow: a Hann window — the standard general-purpose DSP window. +// Tapering the block's edges to zero stops spectral leakage (a tone smearing +// across many bins because the block isn't a whole number of cycles). Also +// DC-strips the 24-in-32 samples to floats for the FFT. +// - magnitudesToBands: groups the n/2 FFT magnitude bins into 16 log-spaced +// bands (pitch is logarithmic — bass gets few bins, treble many) with a plain +// geometric (equal-ratio) bin split, normalises to 0..255, and picks the +// single loudest bin as the dominant peak. + +// Hann window coefficient at sample `i` of `n`: w(i) = 0.5 - 0.5*cos(2πi/(n-1)). +inline float hannWindow(size_t i, size_t n) { + if (n <= 1) return 1.0f; + const float x = 6.28318530718f * static_cast(i) / static_cast(n - 1); + return 0.5f - 0.5f * std::cos(x); +} + +// Window `n` samples (24-in-32, DC stripped) into `out` floats ready for the FFT. +// DC removal here mirrors AudioLevel's — a windowed-but-DC-biased block dumps all +// its energy into bin 0 and swamps the real peak. +inline void applyWindow(const int32_t* samples, size_t n, float* out) { + if (!samples || !out || n == 0) return; + int64_t sum = 0; + for (size_t i = 0; i < n; i++) sum += (samples[i] >> 8); + const float mean = static_cast(sum) / static_cast(n); + for (size_t i = 0; i < n; i++) { + const float s = static_cast(samples[i] >> 8) - mean; + out[i] = s * hannWindow(i, n); + } +} + +// Group `nMag` FFT magnitudes (covering DC..Nyquist over `sampleRate`) into 16 +// log-spaced bands (0..255 each) and report the dominant peak (`peakHz` = its +// frequency, `peakMag` = its 0..255 magnitude). Robust to nMag==0 (all zero). +// +// `noiseFloor` and `gain` condition the bands exactly like the level path +// (AudioLevel.h): each band's scaled magnitude has `noiseFloor` subtracted (so a +// quiet idle spectrum — the mic's own noise — gates to 0 instead of flickering +// the LEDs) and is then multiplied by `gain`/16 (16 = unity) for live brightness +// control. Same knobs, same meaning, both the level and the spectrum. +inline void magnitudesToBands(const float* mag, size_t nMag, uint32_t sampleRate, + uint16_t noiseFloor, uint16_t gain, + uint8_t bands[16], uint16_t& peakHz, uint16_t& peakMag) { + for (uint8_t b = 0; b < 16; b++) bands[b] = 0; + peakHz = 0; + peakMag = 0; + if (!mag || nMag == 0 || sampleRate == 0) return; + + // Hz per bin = sampleRate / (2 * nMag). + const float binHz = static_cast(sampleRate) / (2.0f * static_cast(nMag)); + + // 17 log-spaced bin-index edges: edge[e] = nMag^(e/16), so edge[0]=1 (skip + // DC), edge[16]=nMag, each band spanning the same frequency *ratio* — a plain + // geometric split, the standard way to map linear FFT bins onto pitch. + size_t edge[17]; + for (uint8_t e = 0; e <= 16; e++) { + const float frac = static_cast(e) / 16.0f; + size_t ix = static_cast(std::pow(static_cast(nMag), frac)); + if (ix < 1) ix = 1; + if (ix > nMag) ix = nMag; + edge[e] = ix; + } + + // Magnitude → 0..255 on the shared LOGARITHMIC (dB) scale (magToByte, in + // AudioLevel.h) — the same mapping the level/VU path uses, so noiseFloor/gain + // mean one thing across both. These are the generic per-display knobs (not + // per-band) — the recognisable "range + sensitivity" pair an analyser exposes. + auto toByte = [noiseFloor, gain](float m) -> uint8_t { + return magToByte(m, noiseFloor, gain); + }; + + float peakVal = 0.0f; + size_t peakBin = 0; + for (size_t i = 1; i < nMag; i++) // single peak scan (skip DC) + if (mag[i] > peakVal) { peakVal = mag[i]; peakBin = i; } + + for (uint8_t b = 0; b < 16; b++) { + size_t lo = edge[b], hi = edge[b + 1]; + if (hi <= lo) hi = lo + 1; + if (hi > nMag) hi = nMag; + // Peak (not average) magnitude in the band: a narrow tone shouldn't be + // diluted by the empty bins of a wide treble band — this is what makes a + // single tone light ONE band instead of smearing across many. + float best = 0.0f; + for (size_t i = lo; i < hi; i++) if (mag[i] > best) best = mag[i]; + bands[b] = toByte(best); + } + + if (peakVal > 0.0f) { + peakHz = static_cast(static_cast(peakBin) * binHz); + peakMag = toByte(peakVal); + } +} + +} // namespace mm diff --git a/src/core/AudioFrame.h b/src/core/AudioFrame.h new file mode 100644 index 00000000..5aa3cf10 --- /dev/null +++ b/src/core/AudioFrame.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +namespace mm { + +// One snapshot of analysed audio, produced by AudioModule (src/core/AudioModule.h) +// once per render tick and consumed by audio-reactive effects (AudioVolumeEffect, +// AudioSpectrumEffect). The producer/consumer-via-plain-struct model the codebase +// already uses (PreviewDriver writes pixels HttpServer reads); the struct is the +// whole contract between the two, so effects never touch I2S or the FFT. +// +// POD by design: a flat value type the producer fills and the consumer reads, no +// ownership, no methods — copy it or hold a `const AudioFrame*` to the module's +// latest. All fields are pre-scaled to small integers so an effect does integer +// math straight off them (the hot-path rule); the float FFT magnitudes never +// leave the module. +struct AudioFrame { + uint16_t level = 0; // overall sound level (RMS), 0..255-ish — the VU value + uint16_t peakHz = 0; // dominant frequency this frame, in Hz (0 = none) + uint16_t peakMag = 0; // magnitude of that peak (gates the peakHz update) + uint8_t bands[16] = {}; // 16 log-spaced frequency-band magnitudes, 0..255 + // (bass = bands[0], treble = bands[15]) +}; + +} // namespace mm diff --git a/src/core/AudioLevel.h b/src/core/AudioLevel.h new file mode 100644 index 00000000..14601041 --- /dev/null +++ b/src/core/AudioLevel.h @@ -0,0 +1,125 @@ +#pragma once + +#include "core/AudioFrame.h" + +#include // log10 for the shared dB mapping +#include +#include + +namespace mm { + +// Shared magnitude → 0..255 mapping on a LOGARITHMIC (decibel) scale — used by +// BOTH the level path (this file) and the spectrum path (AudioBands.h) so a VU +// meter and a spectrum bar share one consistent scaling. dB = 20·log10(m) is +// mapped through a window [floorDb, floorDb+spanDb] → [0,255]: +// - `noiseFloor` sets the window bottom: floorDb = 60 + noiseFloor/2 dB. +// - `gain` is sensitivity, the INTUITIVE direction — HIGHER gain = NARROWER +// window = a sound fills more of the range: spanDb = (255-gain)/4 + 4. +// Human hearing is logarithmic and FFT/RMS magnitudes span a huge range, so a +// linear map crushes the quiet or saturates the loud; this is the standard fix. +inline uint8_t magToByte(float m, uint16_t noiseFloor, uint16_t gain) { + if (m <= 1.0f) return 0; + const float floorDb = 60.0f + static_cast(noiseFloor) * 0.5f; + const float spanDb = static_cast(255 - gain) * 0.25f + 4.0f; + const float t = (20.0f * std::log10(m) - floorDb) / spanDb; + if (t <= 0.0f) return 0; + if (t >= 1.0f) return 255; + return static_cast(t * 255.0f); +} + +// DC-blocker: the standard one-pole/one-zero high-pass that removes the constant +// (DC) offset and sub-bass rumble from the sample stream before any analysis — +// y[n] = x[n] - x[n-1] + R·y[n-1]. R near 1 sets the cutoff: R = 0.99 ≈ 40 Hz at +// 22 kHz. State (the two delay registers) persists across blocks, so the filter +// is continuous frame to frame. Hot-path-trivial: one subtract + one multiply-add +// per sample, two floats of state, no allocation. Host-tested. +struct DcBlocker { + float xPrev = 0.0f; // x[n-1] + float yPrev = 0.0f; // y[n-1] + + void reset() { xPrev = 0.0f; yPrev = 0.0f; } + + // Filter `n` samples in place. R is the pole (0..1); higher = lower cutoff. + void process(int32_t* samples, size_t n, float r = 0.99f) { + if (!samples) return; + for (size_t i = 0; i < n; i++) { + const float x = static_cast(samples[i]); + const float y = x - xPrev + r * yPrev; + xPrev = x; + yPrev = y; + // Clamp before narrowing: casting a float outside the int32 range to + // int32_t is undefined behaviour. A settling transient (or a degenerate + // r) can briefly push y past the bounds, so saturate first. The positive + // bound is 2147483520.0f (2^31 - 128), NOT INT32_MAX: INT32_MAX + // (2147483647) has no exact float and rounds UP to 2^31, which is itself + // out of int32 range — clamping to it would still cast out of range. + // 2147483520 is the largest float strictly below 2^31, so the cast is + // always defined. -2^31 (INT32_MIN) is exactly representable, so it's fine. + const float clamped = y < -2147483648.0f ? -2147483648.0f + : y > 2147483520.0f ? 2147483520.0f : y; + samples[i] = static_cast(clamped); + } + } +}; + +// Sound-level (loudness) analysis for one block of I2S microphone samples — pure +// domain math, no platform header, so it is host-tested without an ESP32 (the +// platform owns only the I2S read that produces these samples; see platform.h +// audioMic*). The same host-testable shape as RmtSymbol.h / LcdSlots.h. +// +// Two facts about an I2S MEMS microphone drive the math here, both straight from +// how the part behaves (e.g. the INMP441 datasheet), not from any tuning recipe: +// - It carries a DC bias. The 24-bit sample stream sits on a large constant +// offset, so a plain RMS is dominated by the bias, not the sound — a silent +// room would read "loud". Subtract the block mean first. +// - Its quietest output is hiss, not zero. A `noiseFloor` threshold treats any +// level below it as silence so idle hiss doesn't twitch the LEDs; `gain` then +// scales what's left. +// +// INMP441 sample format: 24-bit signed data left-justified in a 32-bit slot, so +// the magnitude lives in the top bits. We arithmetic-shift right by 8 to land the +// 24-bit value in an int32, then accumulate in 64-bit so a full block can't +// overflow. + +// 64-bit integer square root (Newton's method, converges in a handful of steps +// for our range). Free function so AudioBands.h can reuse it; the level path +// stays free of (it is otherwise all integer), so this is the one root. +inline uint64_t isqrt64(uint64_t x) { + if (x == 0) return 0; + uint64_t r = x, last; + do { + last = r; + r = (r + x / r) >> 1; + } while (r < last); + return last; +} + +// Analyse `n` samples into `frame.level` — the overall RMS loudness mapped +// through the same log/dB window the bands use (magToByte), so the VU meter and +// the spectrum share one scaling and the noiseFloor/gain knobs mean the same +// thing for both. Empty/null input yields zero (silence), never a crash. +inline void computeLevel(const int32_t* samples, size_t n, + uint16_t noiseFloor, uint16_t gain, AudioFrame& frame) { + if (!samples || n == 0) { + frame.level = 0; + return; + } + + // DC mean of the block. 64-bit sum: n * 2^23 fits easily. + int64_t sum = 0; + for (size_t i = 0; i < n; i++) sum += (samples[i] >> 8); + const int64_t mean = sum / static_cast(n); + + // RMS of the DC-removed signal. + uint64_t sqSum = 0; + for (size_t i = 0; i < n; i++) { + const int64_t v = (samples[i] >> 8) - mean; + sqSum += static_cast(v * v); + } + const uint64_t meanSq = sqSum / static_cast(n); + const uint64_t rms = isqrt64(meanSq); + + frame.level = magToByte(static_cast(rms), noiseFloor, gain); +} + +} // namespace mm diff --git a/src/core/AudioModule.h b/src/core/AudioModule.h new file mode 100644 index 00000000..f0e73730 --- /dev/null +++ b/src/core/AudioModule.h @@ -0,0 +1,237 @@ +#pragma once + +// AudioModule — acquires an audio source and publishes an AudioFrame (an overall +// sound level plus a 16-band frequency spectrum and the dominant peak). The frame +// is always available 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 to arrive (longer than one tick), so a tick that doesn't complete +// a block re-publishes the previous AudioFrame unchanged rather than re-analysing. +// Named for what it does (audio acquisition + analysis), not +// for one source: today the source is a digital I2S MEMS mic (e.g. INMP441, the +// only one wired); the analysis pipeline is source-independent and is meant to +// serve line-in / USB sources behind the platform read seam as they are added. +// It is the PRODUCER in the audio producer/consumer pair; audio-reactive effects +// (AudioVolumeEffect, AudioSpectrumEffect) are the consumers, wired the +// AudioFrame* in main.cpp. +// +// A SystemModule Peripheral child (the role already exists). Chip-agnostic: +// gated on platform::hasI2sMic, inert with a status note on targets without I2S +// and on desktop. The signal math is host-tested domain code (AudioLevel.h, +// AudioBands.h); this module owns the lifecycle, the controls, and the two +// platform seams (the I2S read and the FFT kernel). +// +// Hot path: fixed member scratch buffers (sample block + window + magnitudes), +// one float FFT per loop, no per-loop heap. The mic read is non-blocking enough +// for the tick; a bad init leaves the module idle (zeroed frame), never crashing. + +#include "core/MoonModule.h" +#include "core/AudioFrame.h" +#include "core/AudioLevel.h" +#include "core/AudioBands.h" +#include "platform/platform.h" + +#include +#include // snprintf for the read-out strings +#include + +namespace mm { + +class AudioModule : public MoonModule { +public: + // Block size = FFT size: a power of two. 512 samples at 22050 Hz is ~23 ms of + // audio per frame — fine resolution (~43 Hz/bin) at a modest per-tick cost. + static constexpr size_t kBlock = 512; + static constexpr size_t kMag = kBlock / 2; // real-FFT magnitude bins + + ModuleRole role() const override { return ModuleRole::Peripheral; } + + // Unlike a zero-cost diagnostic peripheral (BoardModule), this module pays a + // real per-tick cost (the FFT) that IS the capability, not an optional extra, + // so it must not run when the user turns it off. We therefore respect `enabled` + // (the default): the Scheduler skips loop() entirely while disabled, so the FFT, + // the level, and the read-outs all stop and the cost goes to zero. Enabled runs + // the full pipeline; removing the module stops it the same way. The read-outs + // hold their last value while disabled (no consumer reads a disabled module). + // (respectsEnabled() defaults to true, so we don't override it.) + + // --- controls: three I2S pins, sample rate, the two conditioning knobs, and + // two read-only read-outs. The pins default to UNSET (0): the module is user- + // added when a board has a mic, and stays idle (no I2S init) until the user + // enters the real GPIOs, so adding it can't grab arbitrary pins or wedge a + // board with no mic. The bench INMP441 wiring is WS=4 / SD=5 / SCK=6. --- + uint16_t wsPin = 0; // word-select / LRCLK + uint16_t sdPin = 0; // serial data in + uint16_t sckPin = 0; // bit clock + // Sample rate is a discrete choice (the standard audio rates), so it's a + // dropdown over a fixed set, not a free number. sampleRateSel indexes + // kSampleRates; sampleRate() resolves it to Hz. Default index 2 = 22050. + uint8_t sampleRateSel = 2; + // Two knobs condition the spectrum + level: + uint8_t floor = 100; // noise floor (dB display floor) — bands/level + // below this read as silence. Raise to keep an + // ambient room dark, lower for a quiet room. + uint8_t gain = 222; // sensitivity — HIGHER = more (a narrower dB window + // so a given sound fills more of the bar). + + static constexpr uint16_t kSampleRates[] = {8000, 16000, 22050, 44100}; + static constexpr uint8_t kSampleRateCount = 4; + uint32_t sampleRate() const { return kSampleRates[sampleRateSel < kSampleRateCount + ? sampleRateSel : 2]; } + + void onBuildControls() override { + controls_.addUint16("wsPin", wsPin); + controls_.addUint16("sdPin", sdPin); + controls_.addUint16("sckPin", sckPin); + static constexpr const char* kRateOptions[] = {"8000", "16000", "22050", "44100"}; + controls_.addSelect("sampleRate", sampleRateSel, kRateOptions, kSampleRateCount); + controls_.addUint8("floor", floor, 0, 255); + controls_.addUint8("gain", gain, 1, 255); + // Read-only live read-outs (formatted in loop1s). Derived every second, + // nothing to persist, so ReadOnly (the display-only type) not a flipped + // Text — same idiom as SystemModule's uptime/fps. + controls_.addReadOnly("level", levelStr_, sizeof(levelStr_)); + controls_.addReadOnly("peakHz", peakStr_, sizeof(peakStr_)); + MoonModule::onBuildControls(); + } + + // A pin or rate change rebuilds the I2S channel. + bool controlChangeTriggersBuildState(const char* name) const override { + return std::strcmp(name, "wsPin") == 0 || std::strcmp(name, "sdPin") == 0 + || std::strcmp(name, "sckPin") == 0 || std::strcmp(name, "sampleRate") == 0; + } + + void onBuildState() override { reinit(); MoonModule::onBuildState(); } + void setup() override { active_ = this; reinit(); } + void teardown() override { + deinit(); + if (active_ == this) active_ = nullptr; // effects fall back to silence + } + + // The latest analysed frame — what effects read. Always valid (zeroed until + // the first successful read), so a consumer never dereferences null and a + // mic-less build just sees silence. + const AudioFrame* audioFrame() const { return &frame_; } + + // Process-wide accessor for the consumers (audio effects). There is one mic, + // and an effect can be added/removed via the UI at any time, so it can't rely + // on a boot-time setter — it asks here. Returns the live mic's frame while one + // exists, else a static all-silent frame, so an effect added before/without a + // mic still reads valid silence instead of null. The active instance registers + // itself in setup() and clears the pointer in teardown(), so add/remove in any + // order leaves a coherent answer (the robustness rule). + static const AudioFrame* latestFrame() { + static const AudioFrame kSilence{}; + return active_ ? &active_->frame_ : &kSilence; + } + + void loop() override { + if constexpr (!platform::hasI2sMic) return; // inert off I2S targets + if (!inited_) return; // bad init → idle (zero frame) + + // Drain whatever the DMA holds this tick (non-blocking) into the free tail + // of the block accumulator. A full kBlock takes ~23 ms to arrive (longer + // than one render tick), so each tick contributes a partial; we analyse + // once the accumulator is full, then reset it. + const size_t n = platform::audioMicRead(mic_, samples_ + filled_, kBlock - filled_); + if (n == 0) return; // nothing ready this tick + filled_ += n; + if (filled_ < kBlock) return; // wait for a whole block + filled_ = 0; // consumed below; refill next + + // DC-blocker high-pass (~40 Hz): removes the constant offset + sub-bass + // rumble before any analysis, so they can't leak into the low bands. The + // filter is continuous across blocks (state in dc_). + dc_.process(samples_, kBlock); + + // Level: overall loudness (RMS), independent of the FFT — it fluctuates + // with how loud the room is. Uses a gentler floor than the bands (half), + // so the VU keeps moving with volume instead of being gated hard like the + // per-band display. + computeLevel(samples_, kBlock, static_cast(floor / 2), gain, frame_); + + // Spectrum: window -> FFT -> 16 log bands, same floor/gain mapping. + uint16_t peakHz = 0, peakMag = 0; + applyWindow(samples_, kBlock, windowed_); + platform::audioFft(windowed_, kBlock, mag_); + magnitudesToBands(mag_, kMag, sampleRate(), floor, gain, + frame_.bands, peakHz, peakMag); + + // Peak frequency: the exact-Hz FFT bin, held when there's no real signal so + // it doesn't wander in silence. + if (peakMag > 8) { frame_.peakHz = peakHz; frame_.peakMag = peakMag; } + } + + void loop1s() override { + std::snprintf(levelStr_, sizeof(levelStr_), "%u", static_cast(frame_.level)); + std::snprintf(peakStr_, sizeof(peakStr_), "%u Hz", static_cast(frame_.peakHz)); + MoonModule::loop1s(); + } + +private: + // The mic that latestFrame() hands to effects. One in practice; the last one + // to setup() wins, teardown() clears it. inline so the header stays standalone. + static inline AudioModule* active_ = nullptr; + + platform::AudioMicHandle mic_; + bool inited_ = false; + size_t filled_ = 0; // samples accumulated toward the next full block + DcBlocker dc_; // ~40 Hz high-pass, continuous across blocks + + // Fixed hot-path scratch — sized once, never reallocated. ~6 KB total + // (2 KB samples + 2 KB windowed + 1 KB magnitudes), DRAM-resident. + int32_t samples_[kBlock] = {}; + float windowed_[kBlock] = {}; + float mag_[kMag] = {}; + + AudioFrame frame_; + + char levelStr_[12] = {}; + char peakStr_[12] = {}; + + static constexpr const char* kInitFailMsg = "mic init failed — check pins / rate"; + + void reinit() { + if constexpr (!platform::hasI2sMic) { + setStatus("mic: no I2S on this platform", Severity::Warning); + return; + } + deinit(); + // Pins unset (the default until the user wires a mic): stay idle, don't + // attempt an I2S init. A 0 GPIO is not a valid mic pin, and initialising + // I2S on unset/arbitrary pins is what hung a mic-less board's boot. + if (wsPin == 0 || sdPin == 0 || sckPin == 0) { + setStatus("mic: set wsPin / sdPin / sckPin", Severity::Status); + return; + } + inited_ = platform::audioMicInit(mic_, wsPin, sdPin, sckPin, sampleRate()); + if (!inited_) { + setStatus(kInitFailMsg, Severity::Error); + return; + } + dc_.reset(); // start the high-pass clean for the new stream + // The INMP441 emits ~250 ms of power-on settling garbage after the clock + // starts. The read is non-blocking (hot-path rule), so we can't drain a + // fixed sample count here at init — the DMA has barely filled. Instead the + // settling samples flow through the first few loop() reads and the level / + // bands self-correct within that quarter-second; no separate discard is + // needed, and the frame stays valid (zeroed) until then. + // Clear any prior status now the mic is live — not just kInitFailMsg, but + // also the "set wsPin / sdPin / sckPin" note from the unset-pin path, which + // would otherwise persist and mislead after the user fills the pins in. + clearStatus(); + } + + void deinit() { + if constexpr (!platform::hasI2sMic) return; + if (inited_) platform::audioMicDeinit(mic_); + inited_ = false; + filled_ = 0; + // Publish silence: latestFrame() hands frame_ to consumers whenever this is + // the active mic, independent of inited_. Without this, a mic that worked + // and then lost its bus (a failed reinit after a pin edit, or teardown) + // would leave the last real frame frozen on the LEDs instead of going dark. + frame_ = AudioFrame{}; + } +}; + +} // namespace mm diff --git a/src/core/Control.cpp b/src/core/Control.cpp index baa77025..9de49b66 100644 --- a/src/core/Control.cpp +++ b/src/core/Control.cpp @@ -108,11 +108,12 @@ void writeControlValue(JsonSink& sink, const ControlDescriptor& c) { void writeControlMetadata(JsonSink& sink, const ControlDescriptor& c) { switch (c.type) { case ControlType::Uint8: - sink.appendf(",\"min\":%d,\"max\":%d", - static_cast(c.min), static_cast(c.max)); - return; + case ControlType::Uint16: case ControlType::Int16: - sink.appendf(",\"min\":%d,\"max\":%d", c.min, c.max); + // All three numeric sliders carry a real [min,max] the UI renders + // as a range; %d on the int32 fields covers the full uint16 span. + sink.appendf(",\"min\":%d,\"max\":%d", static_cast(c.min), + static_cast(c.max)); return; case ControlType::ReadOnlyInt: { // aux holds a borrowed const char* unit suffix (set via @@ -134,7 +135,6 @@ void writeControlMetadata(JsonSink& sink, const ControlDescriptor& c) { sink.appendf(",\"total\":%lu", static_cast(c.aux)); return; // Everything else: no extras. - case ControlType::Uint16: case ControlType::Bool: case ControlType::Text: case ControlType::Password: @@ -167,14 +167,13 @@ ApplyResult applyControlValue(const ControlDescriptor& c, } case ControlType::Uint16: { int v = mm::json::parseInt(json, key); - // Strict: out-of-natural-range (uint16 wraps below 0 / above - // UINT16_MAX) fails. Clamp: snap into the type range. There's - // no c.min/c.max bound here (uint8 descriptor fields can't - // bound a uint16 range), only the natural-type bound. - if (policy == ApplyPolicy::Strict && (v < 0 || v > UINT16_MAX)) { + // Strict: out-of-[min,max] fails. Clamp: snap into [min,max]. The + // descriptor's int32 min/max now carry a real uint16 range (default + // 0..UINT16_MAX = no constraint), so this matches Uint8/Int16. + if (policy == ApplyPolicy::Strict && (v < c.min || v > c.max)) { return ApplyResult::OutOfRange; } - return clampInto(static_cast(c.ptr), v, 0, UINT16_MAX); + return clampInto(static_cast(c.ptr), v, c.min, c.max); } case ControlType::Int16: { int v = mm::json::parseInt(json, key); diff --git a/src/core/Control.h b/src/core/Control.h index bfc01145..e860fd59 100644 --- a/src/core/Control.h +++ b/src/core/Control.h @@ -71,8 +71,12 @@ struct ControlDescriptor { const char* name = nullptr; uintptr_t aux = 0; // Progress: total capacity. Select: pointer to options array. ControlType type = ControlType::Uint8; - int16_t min = 0; // Uint8/Int16: UI clamp range. Text/Password/ReadOnly: max = bufSize, min unused. - int16_t max = 255; // Uint16/Select: natural range, UI ignores these fields. + // int32_t (not int16_t) so the same fields bound every numeric type: Int16's + // negatives (down to -32768) AND Uint16's full 0..65535 range, which a 16-bit + // field couldn't hold. Uint8/Uint16/Int16 all carry a real UI slider range + // here; Text/Password/ReadOnly reuse max as the buffer size (min unused). + int32_t min = 0; + int32_t max = 255; bool hidden = false; // UI visibility flag. Set via ControlList::setHidden() after addX(). // Persistence ignores this — hidden controls are still saved/loaded // so toggling visibility doesn't lose state. @@ -100,12 +104,14 @@ class ControlList { controls_[count_++] = {&var, name, 0, ControlType::Uint8, min, max}; } - // c.min/c.max are uint8_t so they can't bound a uint16 range. Persistence - // and the live setter rely on the natural type range (0..UINT16_MAX) here, - // not on c.min/c.max. - void addUint16(const char* name, uint16_t& var) { + // min/max default to the full type range (no UI constraint) when omitted; + // pass explicit bounds (e.g. addUint16("sampleRate", r, 8000, 48000)) to get + // a bounded slider in the UI and server-side clamping on write — the same + // contract as addUint8/addInt16, now that the descriptor's min/max are int32. + void addUint16(const char* name, uint16_t& var, + uint16_t min = 0, uint16_t max = UINT16_MAX) { grow(); - controls_[count_++] = {&var, name, 0, ControlType::Uint16, 0, 0}; + controls_[count_++] = {&var, name, 0, ControlType::Uint16, min, max}; } // lengthType (int16_t) — signed wire format so negative values round-trip diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp index 2369abad..cfd34dec 100644 --- a/src/core/HttpServerModule.cpp +++ b/src/core/HttpServerModule.cpp @@ -491,11 +491,13 @@ void HttpServerModule::handleSetControl(platform::TcpConnection& conn, const cha sendResponse(conn, 400, "application/json", "{\"error\":\"control is read-only\"}"); return; } - // Rebuild controls only for Select (dynamic onBuildControls re-evaluates the - // visible set, e.g. NetworkModule's static-IP fields). - if (c.type == ControlType::Select) { - target->rebuildControls(); - } + // Rebuild the control list after every change so onBuildControls() can + // re-evaluate which controls are visible for the new value — any control + // can reshape the list (a Select picking static-IP fields, a checkbox + // revealing its options). rebuildControls() is clear()+onBuildControls(), + // which the contract requires to be cheap and idempotent, so running it + // per-change costs nothing for the common case where the list is unchanged. + target->rebuildControls(); // Three-tier control-change reaction (see MoonModule::onUpdate): // 1. onUpdate — always, cheap. Lets the module recompute a small LUT etc. // 2. rebuild — only when the control changes physical dims / mapping shape diff --git a/src/core/ImprovProvisioningModule.h b/src/core/ImprovProvisioningModule.h index 1dd71a29..51619a31 100644 --- a/src/core/ImprovProvisioningModule.h +++ b/src/core/ImprovProvisioningModule.h @@ -58,7 +58,8 @@ class ImprovProvisioningModule : public MoonModule { &pendingCredentials_, statusStr_, sizeof(statusStr_), pendingBoard_, sizeof(pendingBoard_), - &pendingBoardReady_); + &pendingBoardReady_, + &pendingTxPower_, &pendingTxPowerReady_); } else { std::strncpy(statusStr_, "not supported on this platform", sizeof(statusStr_) - 1); } @@ -69,6 +70,15 @@ class ImprovProvisioningModule : public MoonModule { } void loop1s() override { + // Vendor SET_TX_POWER RPC — handled BEFORE the credentials on purpose: + // when an installer sends the cap and the credentials back-to-back, + // both flags can land within one tick, and the cap must be persisted + // before the STA attempt starts or a brown-out-prone board (LOLIN S3) + // fails auth at full power — the exact hole this RPC closes. + if (pendingTxPowerReady_.load(std::memory_order_acquire) && networkModule_) { + networkModule_->setTxPowerSetting(pendingTxPower_); + pendingTxPowerReady_.store(false, std::memory_order_release); + } // The platform task writes credentials into pendingSsid_/pendingPassword_ // then publishes via a release-store on pendingCredentials_. We do an // acquire-load here so the buffer writes are visible before we read @@ -112,6 +122,11 @@ class ImprovProvisioningModule : public MoonModule { // pendingCredentials_, sized to BoardModule's storage (32 bytes). char pendingBoard_[32] = {}; std::atomic pendingBoardReady_{false}; + + // Vendor SET_TX_POWER RPC — the pre-association TX-power cap (whole dBm) + // for brown-out-prone boards; same producer/consumer shape as the above. + uint8_t pendingTxPower_ = 0; + std::atomic pendingTxPowerReady_{false}; }; } // namespace mm diff --git a/src/core/NetworkModule.h b/src/core/NetworkModule.h index 3829b1d0..92fafa9f 100644 --- a/src/core/NetworkModule.h +++ b/src/core/NetworkModule.h @@ -32,6 +32,20 @@ class NetworkModule : public MoonModule { // STA mode active but the state machine still thinking it's in AP. // wifiApStop() drops wifiInitDone_=false so the next ensureWifiInit // registers handlers cleanly. + + // Improv SET_TX_POWER path: persist + apply the TX-power cap (whole dBm, + // 0 = lift). Must run BEFORE setWifiCredentials when both arrive from one + // provisioning flow — brown-out-prone boards (LOLIN S3) fail WiFi auth at + // full power, so the cap has to be in place for the association attempt. + void setTxPowerSetting(uint8_t dBm) { + if (dBm > 21) return; + txPowerSetting_ = dBm; + markDirty(); + FilesystemModule::noteDirty(); // same persist arming as setWifiCredentials + syncTxPower(); // applies now if the radio is up; the + // STA-start path re-applies otherwise + } + void setWifiCredentials(const char* ssid, const char* password) { if (!ssid) return; std::strncpy(ssid_, ssid, sizeof(ssid_) - 1); @@ -160,9 +174,13 @@ class NetworkModule : public MoonModule { // boards.json catalog injects 8 dBm for LOLIN boards. controls_.addInt16("txPowerSetting", txPowerSetting_, 0, 21); } - controls_.addSelect("addressing", addressing_, addressingOptions_, 2); controls_.addBool("mDNS", mdnsEnabled_); + // addressing goes immediately before the static-IP fields it conditions, so + // the dropdown and the fields it reveals stay adjacent (mDNS, unrelated, + // sits above rather than wedged between them). + controls_.addSelect("addressing", addressing_, addressingOptions_, 2); + // Static-IP fields are always bound (so persistence can load them at any time), // but visibility flips based on addressing mode. Toggling the Select triggers a // rebuildControls() in HttpServerModule which re-runs this method and re-evaluates @@ -471,6 +489,19 @@ class NetworkModule : public MoonModule { void syncTxPower() { if constexpr (!platform::hasWiFi) return; if (txPowerSetting_ == appliedTxPowerSetting_) return; + // "No override" (0) with nothing ever applied is a genuine no-op: the + // radio is already at its default ceiling, so there is nothing to push. + // Skipping it is not just an optimisation — calling + // esp_wifi_set_max_tx_power inside the radio-start call stack (this runs + // right after wifiStaInit/startAP) hangs the classic ESP32 on IDF + // v6.1-dev with an interrupt-watchdog reset, boot-looping the device. A + // default board must never touch TX power; a real cap (1..21) still does, + // and lifting a prior cap back to 0 still pushes the ceiling because + // appliedTxPowerSetting_ is then > 0. + if (txPowerSetting_ == 0 && appliedTxPowerSetting_ <= 0) { + appliedTxPowerSetting_ = 0; // mark synced so we don't re-check every tick + return; + } const bool radioUp = (state_ == State::ConnectedSta || state_ == State::WaitingSta || state_ == State::AP); diff --git a/src/core/SystemModule.h b/src/core/SystemModule.h index 11790e36..9008c7c8 100644 --- a/src/core/SystemModule.h +++ b/src/core/SystemModule.h @@ -52,6 +52,9 @@ class SystemModule : public MoonModule { std::snprintf(buildStr_, sizeof(buildStr_), "%s", kBuildDate); std::snprintf(firmwareStr_, sizeof(firmwareStr_), "%s", kFirmwareName); std::snprintf(bootReasonStr_, sizeof(bootReasonStr_), "%s", platform::resetReason()); + if constexpr (platform::hasWifiCoprocessor) { + std::snprintf(coprocStr_, sizeof(coprocStr_), "%s", platform::coprocessorWifi()); + } if (chipFlashVal_ > 0) { std::snprintf(flashStr_, sizeof(flashStr_), "%uMB", @@ -121,6 +124,15 @@ class SystemModule : public MoonModule { controls_.addReadOnly("chip", chipInfo_, sizeof(chipInfo_)); controls_.addReadOnly("sdk", sdkInfo_, sizeof(sdkInfo_)); controls_.addReadOnly("bootReason", bootReasonStr_, sizeof(bootReasonStr_)); + // WiFi co-processor (P4 + on-board C6) firmware read-out. Gated at compile + // time on hasWifiCoprocessor, so the whole control — and the snprintf/query + // cost — vanishes on native-radio builds (classic/S3/desktop) and the + // eth-only P4. Its value proves the C6 slave-firmware state ("C6 fw 2.12.9" + // vs "not detected"). loop1s() refreshes it. + if constexpr (platform::hasWifiCoprocessor) { + std::snprintf(coprocStr_, sizeof(coprocStr_), "%s", platform::coprocessorWifi()); + controls_.addReadOnly("wifiCoproc", coprocStr_, sizeof(coprocStr_)); + } // Chain into children (BoardModule today). Per the override-and-chain // convention in architecture.md § Lifecycle propagation to children: @@ -163,6 +175,13 @@ class SystemModule : public MoonModule { std::snprintf(maxBlockStr_, sizeof(maxBlockStr_), "%uKB", static_cast(platform::maxInternalAllocBlock() / 1024)); + // Refresh the WiFi co-processor status, so the displayed C6 firmware state + // stays current if the link comes up after boot or the C6 is reflashed + // without a host reboot. Compiled out where there's no co-processor. + if constexpr (platform::hasWifiCoprocessor) { + std::snprintf(coprocStr_, sizeof(coprocStr_), "%s", platform::coprocessorWifi()); + } + // Chain to base so children get their loop1s() — a Peripheral formats // its read-only display values here. Overriding loop1s() shadows the // base default that would otherwise propagate. (setup/loop20ms/loop/ @@ -196,6 +215,7 @@ class SystemModule : public MoonModule { // 24 fits the longest current key ("desktop-macos-arm64" = 19) with headroom. char firmwareStr_[24] = {}; char bootReasonStr_[16] = {}; + char coprocStr_[24] = {}; // WiFi co-processor status, e.g. "C6 fw 2.12.9" / "not detected" uint32_t totalInternalVal_ = 0; uint32_t totalHeapVal_ = 0; char flashStr_[12] = {}; diff --git a/src/light/ArtNetPacket.h b/src/light/ArtNetPacket.h new file mode 100644 index 00000000..f269333a --- /dev/null +++ b/src/light/ArtNetPacket.h @@ -0,0 +1,128 @@ +#pragma once + +#include +#include + +namespace mm { + +// ArtNet wire formats — the one place the packet layouts live. The sender +// (drivers/NetworkSendDriver.h) builds packets with it, the receiver +// (effects/NetworkReceiveEffect.h) parses them with it; a unit test round-trips +// build→parse so the two can never drift apart. Sits at the top of src/light/ +// (beside light_types.h) because the protocol is neutral between the drivers +// and effects subfolders. +// +// OpDmx layout (18-byte header + DMX data): +// 0-7 "Art-Net\0" +// 8-9 OpCode, little-endian — OpDmx = 0x5000 +// 10-11 protocol version, big-endian — 14 +// 12 sequence +// 13 physical port +// 14-15 universe, little-endian +// 16-17 data length, big-endian +// 18+ DMX channel data +// +// Discovery (the Resolume/Madrix/xLights node-list handshake): controllers +// broadcast ArtPoll (OpCode 0x2000); every node answers with ArtPollReply +// (OpCode 0x2100, 239 bytes) carrying its IP, names, MAC, and bound universe — +// that reply is what makes the device appear in a controller's output list. + +constexpr uint16_t ARTNET_PORT = 6454; +constexpr size_t MAX_CHANNELS_PER_UNIVERSE = 510; // 170 RGB lights +constexpr size_t ARTNET_HEADER_SIZE = 18; +constexpr size_t ARTNET_POLL_REPLY_SIZE = 239; + +// Build an ArtNet OpDmx packet into outBuf. Returns the total packet size. +// outBuf must be at least ARTNET_HEADER_SIZE + dataLen. +inline size_t buildArtDmxPacket(uint8_t* outBuf, uint16_t universe, uint8_t sequence, + const uint8_t* data, uint16_t dataLen) { + // "Art-Net\0" header + std::memcpy(outBuf, "Art-Net", 8); // includes null terminator + + // OpCode: OpDmx = 0x5000 (little-endian) + outBuf[8] = 0x00; + outBuf[9] = 0x50; + + // Protocol version: 14 (big-endian) + outBuf[10] = 0x00; + outBuf[11] = 0x0e; + + // Sequence + outBuf[12] = sequence; + + // Physical port + outBuf[13] = 0; + + // Universe (little-endian) + outBuf[14] = static_cast(universe & 0xFF); + outBuf[15] = static_cast(universe >> 8); + + // Length (big-endian) + outBuf[16] = static_cast(dataLen >> 8); + outBuf[17] = static_cast(dataLen & 0xFF); + + // DMX data + std::memcpy(outBuf + ARTNET_HEADER_SIZE, data, dataLen); + + return ARTNET_HEADER_SIZE + dataLen; +} + +// Parse + validate an ArtNet OpDmx packet. Returns true and sets the out +// params when pkt is a well-formed OpDmx datagram: "Art-Net\0" magic, OpDmx +// opcode, and a declared data length that fits inside the received bytes +// (dataOut points into pkt — zero copy). Anything else (other opcodes, short +// headers, lying length fields) returns false and the caller drops the packet. +// The protocol-version field is deliberately not checked — be liberal in what +// we accept; the sequence field is the caller's concern (the receive effect +// ignores it: last write wins). +inline bool parseArtDmxPacket(const uint8_t* pkt, size_t len, uint16_t& universeOut, + const uint8_t*& dataOut, uint16_t& dataLenOut) { + if (!pkt || len < ARTNET_HEADER_SIZE) return false; + if (std::memcmp(pkt, "Art-Net", 8) != 0) return false; + if (pkt[8] != 0x00 || pkt[9] != 0x50) return false; // OpDmx only + const uint16_t dataLen = static_cast((pkt[16] << 8) | pkt[17]); + if (dataLen == 0 || dataLen > len - ARTNET_HEADER_SIZE) return false; + universeOut = static_cast(pkt[14] | (pkt[15] << 8)); + dataOut = pkt + ARTNET_HEADER_SIZE; + dataLenOut = dataLen; + return true; +} + +// True when pkt is an ArtPoll — a controller asking "which nodes are out +// there?". The minimal ArtPoll is 14 bytes (header + protVer + flags + prio). +inline bool isArtPoll(const uint8_t* pkt, size_t len) { + return pkt && len >= 14 && std::memcmp(pkt, "Art-Net", 8) == 0 + && pkt[8] == 0x00 && pkt[9] == 0x20; // OpPoll, little-endian +} + +// Build the minimal ArtPollReply controllers actually read: our IP + port, +// short/long name, MAC, style "node", one output port bound to +// `universeStart`. Every other field stays zero — accepted by Resolume, +// Madrix and xLights, which key on the fields above. outBuf must be at least +// ARTNET_POLL_REPLY_SIZE bytes. +inline size_t buildArtPollReply(uint8_t* outBuf, const uint8_t ip[4], + const uint8_t mac[6], const char* shortName, + const char* longName, uint16_t universeStart) { + std::memset(outBuf, 0, ARTNET_POLL_REPLY_SIZE); + std::memcpy(outBuf, "Art-Net", 8); + outBuf[8] = 0x00; outBuf[9] = 0x21; // OpPollReply, little-endian + std::memcpy(outBuf + 10, ip, 4); + outBuf[14] = 0x36; outBuf[15] = 0x19; // port 6454, little-endian + // 15-bit port address: NetSwitch = bits 14-8, SubSwitch = bits 7-4, + // SwOut[0] = bits 3-0 — together they re-assemble universeStart. + outBuf[18] = static_cast((universeStart >> 8) & 0x7F); + outBuf[19] = static_cast((universeStart >> 4) & 0x0F); + outBuf[20] = 0x00; outBuf[21] = 0xFF; // OEM: unknown/generic + // Short name (18 bytes incl NUL) and long name (64 bytes incl NUL). + std::strncpy(reinterpret_cast(outBuf + 26), shortName, 17); + std::strncpy(reinterpret_cast(outBuf + 44), longName, 63); + outBuf[173] = 1; // NumPorts (lo) = 1 + outBuf[174] = 0x80; // PortTypes[0]: can output DMX + outBuf[182] = 0x80; // GoodOutput[0]: outputting + outBuf[190] = static_cast(universeStart & 0x0F); // SwOut[0] + // Style at 200 stays 0x00 = StNode. + std::memcpy(outBuf + 201, mac, 6); + return ARTNET_POLL_REPLY_SIZE; +} + +} // namespace mm diff --git a/src/light/DdpPacket.h b/src/light/DdpPacket.h new file mode 100644 index 00000000..5a71e58a --- /dev/null +++ b/src/light/DdpPacket.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include + +namespace mm { + +// DDP (Distributed Display Protocol, 3waylabs) wire format — the one place the +// layout lives, shared by NetworkSendDriver (build) and NetworkReceiveEffect +// (parse); a unit test round-trips build→parse. Same shape as ArtNetPacket.h. +// +// DDP is the high-throughput choice: a 10-byte header and 1440-byte payload +// carry 480 RGB lights per packet vs ArtNet's 170 — and per-packet cost is +// what dominates the wire time (~280 µs Ethernet / ~1140 µs WiFi per packet). +// +// Layout (10-byte header + data; multi-byte fields BIG-endian): +// 0 flags: VV=01 in the top bits (0x40), 0x01 = push (last packet of frame) +// 1 sequence (low 4 bits; 0 = unused) +// 2 data type (0x01 = RGB convention; receivers accept loosely) +// 3 destination id (1 = default display) +// 4-7 data offset — BYTE position in the display buffer +// 8-9 data length +// 10+ data +// +// Validation is deliberately thin: the 2-bit version field is the only magic, +// so a stray non-DDP datagram with byte0 ≈ 0x4x can parse "successfully" with +// a garbage offset — the receiver's offset bound check absorbs that, and the +// real protocol discriminator is the dedicated port (4048), not the header. + +constexpr uint16_t DDP_PORT = 4048; +constexpr size_t DDP_HEADER_SIZE = 10; +constexpr size_t DDP_MAX_PAYLOAD = 1440; // 480 RGB / 360 RGBW lights; divisible by 3 and 4 + +// Build a DDP data packet. outBuf must be at least DDP_HEADER_SIZE + dataLen. +// `push` marks the last packet of a frame (receivers that double-buffer show +// the frame on push; ours streams into staging and doesn't need it). +inline size_t buildDdpPacket(uint8_t* outBuf, uint32_t offset, bool push, + const uint8_t* data, uint16_t dataLen) { + outBuf[0] = static_cast(0x40 | (push ? 0x01 : 0x00)); + outBuf[1] = 0; // sequence unused + outBuf[2] = 0x01; // RGB + outBuf[3] = 0x01; // default display + outBuf[4] = static_cast(offset >> 24); + outBuf[5] = static_cast(offset >> 16); + outBuf[6] = static_cast(offset >> 8); + outBuf[7] = static_cast(offset & 0xFF); + outBuf[8] = static_cast(dataLen >> 8); + outBuf[9] = static_cast(dataLen & 0xFF); + std::memcpy(outBuf + DDP_HEADER_SIZE, data, dataLen); + return DDP_HEADER_SIZE + dataLen; +} + +// Parse + validate a DDP data packet: version bits and a declared length that +// fits the datagram. Sequence, data type, destination and the push flag are +// deliberately ignored (hold-last-frame staging makes push moot; last write +// wins — the same stance as ArtNet's ignored sequence). dataOut points into +// pkt (zero copy). +inline bool parseDdpPacket(const uint8_t* pkt, size_t len, uint32_t& offsetOut, + const uint8_t*& dataOut, uint16_t& dataLenOut) { + if (!pkt || len < DDP_HEADER_SIZE) return false; + if ((pkt[0] & 0xC0) != 0x40) return false; // version must be 01 + const uint16_t dataLen = static_cast((pkt[8] << 8) | pkt[9]); + if (dataLen == 0 || dataLen > len - DDP_HEADER_SIZE) return false; + offsetOut = (static_cast(pkt[4]) << 24) | (static_cast(pkt[5]) << 16) + | (static_cast(pkt[6]) << 8) | pkt[7]; + dataOut = pkt + DDP_HEADER_SIZE; + dataLenOut = dataLen; + return true; +} + +} // namespace mm diff --git a/src/light/E131Packet.h b/src/light/E131Packet.h new file mode 100644 index 00000000..1c730e12 --- /dev/null +++ b/src/light/E131Packet.h @@ -0,0 +1,114 @@ +#pragma once + +#include +#include + +namespace mm { + +// E1.31 (streaming ACN / sACN) data-packet wire format — the one place the +// layout lives, shared by NetworkSendDriver (build) and NetworkReceiveEffect +// (parse); a unit test round-trips build→parse so the two can never drift. +// Same shape as ArtNetPacket.h: constants + two inline free functions. +// +// Layout (126-byte header + DMX data; every multi-byte field BIG-endian): +// Root layer 0–37: +// 0-1 preamble size 0x0010 2-3 postamble size 0x0000 +// 4-15 packet identifier "ASC-E1.17\0\0\0" +// 16-17 flags+length 0x7000 | (totalLen − 16) +// 18-21 vector 0x00000004 (E131 data) +// 22-37 CID — sender's stable 16-byte component id +// Framing layer 38–114: +// 38-39 flags+length 0x7000 | (totalLen − 38) +// 40-43 vector 0x00000002 +// 44-107 source name (64 bytes, NUL-padded) +// 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) +// DMP layer 115–125: +// 115-116 flags+length 0x7000 | (totalLen − 115) +// 117 vector 0x02 118 address & data type 0xA1 +// 119-120 first property address 0x0000 +// 121-122 address increment 0x0001 +// 123-124 property value count = 1 + dataLen +// 125 DMX start code 0x00 +// 126+ channel data + +constexpr uint16_t E131_PORT = 5568; +constexpr size_t E131_HEADER_SIZE = 126; +constexpr size_t E131_CID_LENGTH = 16; + +namespace detail { +inline void putU16(uint8_t* p, uint16_t v) { + p[0] = static_cast(v >> 8); + p[1] = static_cast(v & 0xFF); +} +inline uint16_t getU16(const uint8_t* p) { + return static_cast((p[0] << 8) | p[1]); +} +} // namespace detail + +// Build an E1.31 data packet. outBuf must be at least E131_HEADER_SIZE + +// dataLen. Priority is fixed at 100 (the spec default) and sync address at 0 — +// neither has a use here until a consumer appears. +inline size_t buildE131Packet(uint8_t* outBuf, uint16_t universe, uint8_t sequence, + const uint8_t cid[E131_CID_LENGTH], + const uint8_t* data, uint16_t dataLen) { + const size_t totalLen = E131_HEADER_SIZE + dataLen; + std::memset(outBuf, 0, E131_HEADER_SIZE); + + // Root layer + detail::putU16(outBuf + 0, 0x0010); // preamble size + // postamble size stays 0 + std::memcpy(outBuf + 4, "ASC-E1.17\0\0\0", 12); + detail::putU16(outBuf + 16, static_cast(0x7000 | (totalLen - 16))); + outBuf[21] = 0x04; // vector 0x00000004 + std::memcpy(outBuf + 22, cid, E131_CID_LENGTH); + + // Framing layer + detail::putU16(outBuf + 38, static_cast(0x7000 | (totalLen - 38))); + outBuf[43] = 0x02; // vector 0x00000002 + std::memcpy(outBuf + 44, "projectMM", 9); // source name (NUL-padded) + outBuf[108] = 100; // priority (spec default) + // sync address stays 0 + outBuf[111] = sequence; + // options stay 0 + detail::putU16(outBuf + 113, universe); + + // DMP layer + detail::putU16(outBuf + 115, static_cast(0x7000 | (totalLen - 115))); + outBuf[117] = 0x02; // vector: set property + outBuf[118] = 0xA1; // address & data type + // first property address stays 0x0000 + outBuf[122] = 0x01; // address increment + detail::putU16(outBuf + 123, static_cast(1 + dataLen)); + // start code stays 0x00 + + std::memcpy(outBuf + E131_HEADER_SIZE, data, dataLen); + return totalLen; +} + +// Parse + validate an E1.31 data packet. Liberal like parseArtDmxPacket — +// checks the ACN identifier, the three layer vectors, the DMX start code, and +// that the declared property count fits the datagram; priority/sequence/sync +// are deliberately ignored (last write wins, same stance as ArtNet's sequence). +// dataOut points into pkt (zero copy). +inline bool parseE131Packet(const uint8_t* pkt, size_t len, uint16_t& universeOut, + const uint8_t*& dataOut, uint16_t& dataLenOut) { + if (!pkt || len < E131_HEADER_SIZE) return false; + if (std::memcmp(pkt + 4, "ASC-E1.17\0\0\0", 12) != 0) return false; + if (pkt[18] != 0 || pkt[19] != 0 || pkt[20] != 0 || pkt[21] != 0x04) return false; + if (pkt[40] != 0 || pkt[41] != 0 || pkt[42] != 0 || pkt[43] != 0x02) return false; + if (pkt[117] != 0x02) return false; + if (pkt[125] != 0x00) return false; // only DMX start code 0 carries light data + const uint16_t propCount = detail::getU16(pkt + 123); + if (propCount < 1) return false; + const uint16_t dataLen = static_cast(propCount - 1); + if (dataLen == 0 || dataLen > len - E131_HEADER_SIZE) return false; + universeOut = detail::getU16(pkt + 113); + dataOut = pkt + E131_HEADER_SIZE; + dataLenOut = dataLen; + return true; +} + +} // namespace mm diff --git a/src/light/drivers/Correction.h b/src/light/drivers/Correction.h index e16fdf4b..5078d3ff 100644 --- a/src/light/drivers/Correction.h +++ b/src/light/drivers/Correction.h @@ -21,7 +21,7 @@ inline constexpr uint8_t kLightPresetCount = // it on a brightness / light-preset change (cheap, cold path), and hands a const // pointer to each driver child. apply() is the hot-path per-light transform. // -// Today only ArtNetSendDriver consumes it; future LED drivers (WS2812 via RMT, +// Today only NetworkSendDriver consumes it; future LED drivers (WS2812 via RMT, // APA102 via SPI) apply the same correction before their protocol encode. // // Brightness uses a single 256-entry LUT applied to every channel. Gamma / diff --git a/src/light/drivers/Drivers.h b/src/light/drivers/Drivers.h index f7a3db0f..36c0b1fd 100644 --- a/src/light/drivers/Drivers.h +++ b/src/light/drivers/Drivers.h @@ -42,16 +42,74 @@ class DriverBase : public MoonModule { // onBuildState — this hook is just for the preset-driven channel-count change // that doesn't trigger a structural rebuild. virtual void onCorrectionChanged() {} + + // Clear both shared status strings on teardown (frees the owned failBuf_). A + // driver that overrides teardown() for its own peripheral cleanup chains to + // this afterwards: `deinit(); DriverBase::teardown();`. + void teardown() override { clearFailBuf(); clearConfigErr(); } + protected: Layer* layer_ = nullptr; + + // --- 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; + 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; + } + } + + // 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(platform::alloc(kFailBufLen)); + return failBuf_; + } + void clearFailBuf() { + if (failBuf_) { + if (status() == failBuf_) clearStatus(); + platform::free(failBuf_); + failBuf_ = nullptr; + } + } }; class Drivers : public MoonModule { public: const char* acceptsChildRoles() const override { return "driver"; } - uint8_t brightness = 255; - uint8_t lightPreset = 0; // index into kLightPresetOptions; 0 = RGB + // Default low (≈8%). A fresh device with LEDs wired but no power budget set + // (e.g. a strip on USB 5V) draws far less at 20 than at full white, so the + // first boot can't brown out the board before the user sets a safe level. + // The user raises it via the brightness control once their supply is known. + uint8_t brightness = 20; + // GRB (index 2): the wire order of WS2812/SK6812 strips — the common case, + // so a freshly-flashed board with a strip attached shows correct colours + // out of the box. Only the physical output drivers apply this reorder; + // PreviewDriver reads the RGB source buffer directly, so the simulator is + // unaffected. RGB-ordered outputs (some ArtNet/network sinks) flip it back. + uint8_t lightPreset = 2; // index into kLightPresetOptions; 2 = GRB // Two ways to wire the source Layer: // - setLayers(Layers*): bind the container; layer_ is re-resolved from diff --git a/src/light/drivers/LcdLedDriver.h b/src/light/drivers/LcdLedDriver.h new file mode 100644 index 00000000..43dd9139 --- /dev/null +++ b/src/light/drivers/LcdLedDriver.h @@ -0,0 +1,98 @@ +#pragma once + +#include "light/drivers/ParallelLedDriver.h" // shared CRTP body +#include "platform/platform.h" + +#include +#include // std::strcmp + +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). +class LcdLedDriver : public ParallelLedDriver { +public: + // Data pins + loopback pin default to UNSET: they are user-soldered (the strand + // runs to whatever GPIOs the user wired), so a hard-coded default would be a + // guess that could drive a pin the user committed elsewhere — empty until set, + // the driver idles meanwhile (the "default only when it cannot do harm" rule; + // see decisions.md). The LOLIN S3 bench wiring was pins "1,2,4,5,6,7,8,9", + // loopbackRxPin 12 (kept clear of the octal-PSRAM pins 26-37, USB 19/20, and + // strapping pins) — set those again to reproduce the bench. (Base declares + // 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.) + uint16_t clockPin = 10; + uint16_t dcPin = 11; + + // --- CRTP hooks the base calls (all non-virtual; no vtable) --- + + 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"; + + void addBusControls() { + controls_.addUint16("clockPin", clockPin); + controls_.addUint16("dcPin", dcPin); + } + bool busControlTriggersBuild(const char* name) const { + return std::strcmp(name, "clockPin") == 0 || std::strcmp(name, "dcPin") == 0; + } + + bool busInit(size_t frameBytes) { + return platform::lcdWs2812Init(lcd_, laneList_, laneCount_, + clockPin, dcPin, frameBytes); + } + uint8_t* busBuffer() { return platform::lcdWs2812Buffer(lcd_); } + size_t busCapacity() const { return platform::lcdWs2812BufferCapacity(lcd_); } + bool busTransmit(size_t bytes) { return platform::lcdWs2812Transmit(lcd_, bytes); } + void busWait(uint32_t ms) { platform::lcdWs2812Wait(lcd_, ms); } + 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. + platform::RmtLoopbackResult busLoopback(const uint8_t* frame, size_t frameBytes, + size_t dataBytes, uint8_t rowBits) { + return platform::lcdWs2812Loopback(laneList_, laneCount_, clockPin, dcPin, + loopbackRxPin, 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). + void recordBusPins() { lastClockPin_ = clockPin; lastDcPin_ = dcPin; } + bool extraBusPinsCurrent() const { + return lastClockPin_ == clockPin && lastDcPin_ == dcPin; + } + +private: + platform::LcdWs2812Handle lcd_; + uint16_t lastClockPin_ = 0; + uint16_t lastDcPin_ = 0; +}; + +} // namespace mm diff --git a/src/light/drivers/LcdSlots.h b/src/light/drivers/LcdSlots.h new file mode 100644 index 00000000..b837f3f7 --- /dev/null +++ b/src/light/drivers/LcdSlots.h @@ -0,0 +1,64 @@ +#pragma once + +#include + +namespace mm { + +// WS2812 encode for parallel WS2812 buses — the contract between a parallel +// driver (domain) and a parallel peripheral, named for the wire unit it builds +// (one pixel-clock SLOT = one byte on the 8-bit bus), the RmtSymbol.h sibling. +// Used by BOTH the LCD_CAM i80 driver (ESP32-S3, LcdLedDriver) and the Parlio +// driver (ESP32-P4, ParlioLedDriver) — a Parlio bus byte and an i80 bus byte +// are identical (one word per slot, bit L = data line L), so one encoder +// serves both. Pure data transform, no platform include — the host CI encoder +// test (unit_LcdLedEncoder.cpp) pins it with no ESP32. +// +// Technique (hpwit / Adafruit "ESP32uesday" / FastLED S3 lineage — studied, +// not copied): every WS2812 data bit becomes THREE bus slots clocked at +// 2.67 MHz (slot = 375 ns, bit = 1.125 µs): +// +// slot 0: activeMask — every active lane HIGH (the pulse start) +// slot 1: data bits & mask — lane L's current bit at bus bit L +// slot 2: 0x00 — every lane LOW (the pulse tail) +// +// so a "1" bit is HIGH for 2 slots (750 ns ≈ t1h 700 ns) and a "0" bit for +// 1 slot (375 ns ≈ t0h 350 ns). The LedDriverConfig nanosecond fields are +// APPROXIMATED by the slot clock — timing is fixed by the pclk (chosen in +// platform_esp32_lcd.cpp; 375 ns keeps T0H inside even the newest WS2812B +// revisions' ~380 ns max — longer "0" pulses wash strips out white on a +// direct 3.3 V data line). +// +// Lanes-active-mask rule: a lane whose strand is shorter than the longest one +// must appear in NEITHER slot 0 nor slot 1 once its lights are exhausted — +// excluded lanes idle LOW for the rest of the frame instead of flashing white. +// The caller expresses that by clearing the lane's bit in `activeMask`. +// +// Bus bit L = the L-th entry of the driver's `pins` list (D0 = first pin). +// Bits go MSB-first per byte; channel order (GRB, …) is already applied by +// Correction before the encode, so the encoder is order-agnostic (same +// contract as encodeWs2812Symbols). + +// Encode one ROW (the same light index across all lanes) into 3-slot bytes. +// wire: kMaxLanes × 4 corrected wire bytes, lane-major +// (wire[lane * 4 + channel]); only lanes set in activeMask are +// read — inactive lanes may hold garbage. +// activeMask: bit L set = lane L drives this row. +// channels: wire bytes per light (3 RGB / 4 RGBW). +// out: channels * 8 * 3 bytes, fully written. +inline void encodeWs2812LcdSlots(const uint8_t* wire, uint8_t activeMask, + uint8_t channels, uint8_t* out) { + for (uint8_t ch = 0; ch < channels; ch++) { + for (int bit = 7; bit >= 0; bit--) { + uint8_t data = 0; + for (uint8_t lane = 0; lane < 8; lane++) { + if (!(activeMask & (1u << lane))) continue; // inactive: idle LOW + data |= static_cast(((wire[lane * 4 + ch] >> bit) & 1u) << lane); + } + *out++ = activeMask; // slot 0: pulse start + *out++ = data; // slot 1: the bits + *out++ = 0x00; // slot 2: pulse tail + } + } +} + +} // namespace mm diff --git a/src/light/drivers/LedDriverConfig.h b/src/light/drivers/LedDriverConfig.h new file mode 100644 index 00000000..7ade890f --- /dev/null +++ b/src/light/drivers/LedDriverConfig.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +namespace mm { + +// Wire timing for a clockless addressable-LED chipset (WS2812B by default). +// Pure data — no platform include — so the encoder that reads it is host-testable. +// +// WS2812-class chips are a 1-wire NRZ protocol at 800 kHz: every bit is a +// `period_ns` cell that starts HIGH and drops LOW, where the HIGH duration +// encodes 0 vs 1. There is no clock line. The defaults below target the +// reverse-engineered ~600 ns decode threshold (see leddriver-analysis-top-down.md +// §1.1), so they satisfy WS2812, WS2812B and SK6812 at once. +struct LedDriverConfig { + uint32_t t0h_ns = 350; // "0" bit: HIGH for this long, then LOW for the rest + uint32_t t1h_ns = 700; // "1" bit: HIGH for this long, then LOW for the rest + uint32_t period_ns = 1250; // full bit cell (t?h + the trailing LOW) + uint32_t reset_us = 300; // idle-LOW latch between frames (>= 300 us, current silicon) + bool invert = false; // flip output polarity, for inverting level-shifters +}; + +} // namespace mm diff --git a/src/light/drivers/ArtNetSendDriver.h b/src/light/drivers/NetworkSendDriver.h similarity index 55% rename from src/light/drivers/ArtNetSendDriver.h rename to src/light/drivers/NetworkSendDriver.h index 57e80040..f02afa12 100644 --- a/src/light/drivers/ArtNetSendDriver.h +++ b/src/light/drivers/NetworkSendDriver.h @@ -1,25 +1,43 @@ #pragma once +#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 "platform/platform.h" +#include // std::min in the chunk loop #include #include namespace mm { -class ArtNetSendDriver : public DriverBase { +// 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). +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(). + 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 connectIfIpChanged() formats on a stack buffer at the + // a string, so connectIfDestChanged() formats on a stack buffer at the // boundary — the long-lived storage stays integer. uint8_t ip[4] = {192, 168, 1, 70}; - uint16_t universeStart = 0; + uint8_t protocol = 0; // index into kProtocolOptions + uint16_t universeStart = 0; // first universe (ArtNet/E1.31; DDP is byte-addressed) uint8_t fps = 50; void onBuildControls() override { + controls_.addSelect("protocol", protocol, kProtocolOptions, kProtocolCount); controls_.addIPv4("ip", ip); controls_.addUint16("universe_start", universeStart); controls_.addUint8("fps", fps, 1, 120); @@ -27,10 +45,14 @@ class ArtNetSendDriver : public DriverBase { void setup() override { socket_.open(); - // Bind the destination so each per-universe send skips the per-packet - // address parse + route lookup. Re-bound in loop() if the ip control - // changes (see connectIfIpChanged). - connectIfIpChanged(); + // 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(); } void teardown() override { @@ -76,8 +98,8 @@ class ArtNetSendDriver : public DriverBase { if (now - lastSendTime_ < interval) return; lastSendTime_ = now; - // Re-bind the socket if the ip control was changed from the UI. - connectIfIpChanged(); + // Re-bind the socket if the ip or protocol control changed from the UI. + connectIfDestChanged(); // Apply output correction (brightness / channel order / RGBW white) into the // pre-sized corrected_ buffer, then send that. Pure reader — sizing happens @@ -116,61 +138,44 @@ class ArtNetSendDriver : public DriverBase { totalBytes = sourceBuffer_->bytes(); } - // Send all universes in one burst — receiver expects a complete frame + // Send the whole frame in one burst — receivers expect a complete + // frame. The chunking is the only per-protocol difference: ArtNet and + // E1.31 split into 510-channel universes (whole RGB lights, the + // xLights/Falcon convention); DDP packs 1440-byte chunks addressed by + // byte offset, push-flagged on the last packet of the frame. + const size_t chunk = (protocol == 2) ? DDP_MAX_PAYLOAD : MAX_CHANNELS_PER_UNIVERSE; uint16_t universe = universeStart; - + uint8_t packet[DDP_HEADER_SIZE + DDP_MAX_PAYLOAD]; // 1450 B covers all three size_t sent = 0; while (sent < totalBytes) { - size_t chunkLen = totalBytes - sent; - if (chunkLen > MAX_CHANNELS_PER_UNIVERSE) chunkLen = MAX_CHANNELS_PER_UNIVERSE; - - sendUniverse(universe, data + sent, static_cast(chunkLen)); - - sent += chunkLen; + const size_t n = std::min(totalBytes - sent, chunk); + size_t packetLen; + switch (protocol) { + case 1: + packetLen = buildE131Packet(packet, universe, sequence_, cid_, + data + sent, static_cast(n)); + break; + case 2: + packetLen = buildDdpPacket(packet, static_cast(sent), + /*push=*/sent + n >= totalBytes, + data + sent, static_cast(n)); + break; + default: + packetLen = buildArtDmxPacket(packet, universe, sequence_, + data + sent, static_cast(n)); + break; + } + socket_.sendTo(packet, packetLen); + sent += n; universe++; } sequence_++; } - // Public for testability: builds an ArtNet OpDmx packet into outBuf. - // Returns total packet size. outBuf must be at least ARTNET_HEADER_SIZE + dataLen. - static size_t buildPacket(uint8_t* outBuf, uint16_t universe, uint8_t sequence, - const uint8_t* data, uint16_t dataLen) { - // "Art-Net\0" header - std::memcpy(outBuf, "Art-Net", 8); // includes null terminator - - // OpCode: OpDmx = 0x5000 (little-endian) - outBuf[8] = 0x00; - outBuf[9] = 0x50; - - // Protocol version: 14 (big-endian) - outBuf[10] = 0x00; - outBuf[11] = 0x0e; - - // Sequence - outBuf[12] = sequence; - - // Physical port - outBuf[13] = 0; - - // Universe (little-endian) - outBuf[14] = static_cast(universe & 0xFF); - outBuf[15] = static_cast(universe >> 8); - - // Length (big-endian) - outBuf[16] = static_cast(dataLen >> 8); - outBuf[17] = static_cast(dataLen & 0xFF); - - // DMX data - std::memcpy(outBuf + ARTNET_HEADER_SIZE, data, dataLen); - - return ARTNET_HEADER_SIZE + dataLen; - } - - static constexpr uint16_t ARTNET_PORT = 6454; - static constexpr size_t MAX_CHANNELS_PER_UNIVERSE = 510; // 170 RGB lights - static constexpr size_t ARTNET_HEADER_SIZE = 18; + // The packet builds, the constants, and the inverse parses live in + // light/ArtNetPacket.h, light/E131Packet.h and light/DdpPacket.h, shared + // with NetworkReceiveEffect — each wire format exists in exactly one place. // Test-only accessor for the correction-applied buffer. Lets the unit // tests pin the no-allocation-in-loop contract (size set in onBuildState @@ -184,26 +189,28 @@ class ArtNetSendDriver : public DriverBase { Buffer corrected_; // owned: source bytes after brightness/order/white uint8_t sequence_ = 0; uint32_t lastSendTime_ = 0; - uint8_t lastConnectedIp_[4] = {}; // destination the socket is currently bound to (4 octets) - - // Re-bind the connected socket when the ip control differs from what it - // was last bound to. UDP connect() only sets the destination (no - // handshake), so this is cheap; it runs only on an actual change. - // The platform UdpSocket::connect() takes a string IP, so we format the - // octets onto a stack buffer at the call site rather than holding a - // long-lived char[16] member. - void connectIfIpChanged() { - if (std::memcmp(ip, lastConnectedIp_, 4) == 0) return; + uint8_t cid_[E131_CID_LENGTH] = {}; // E1.31 component id, built once in setup() + uint8_t lastConnectedIp_[4] = {}; // destination the socket is currently bound to + uint8_t lastConnectedProtocol_ = 0xFF; // 0xFF = never connected + + static uint16_t protocolPort(uint8_t p) { + return p == 1 ? E131_PORT : p == 2 ? DDP_PORT : ARTNET_PORT; + } + + // Re-bind the connected socket when the ip or protocol control differs + // from what it was last bound to (the port follows the protocol). UDP + // connect() only sets the destination (no handshake), so this is cheap; it + // runs only on an actual change. The platform UdpSocket::connect() takes a + // string IP, so we format the octets onto a stack buffer at the boundary + // rather than holding a long-lived char[16] member. + void connectIfDestChanged() { + if (std::memcmp(ip, lastConnectedIp_, 4) == 0 + && protocol == lastConnectedProtocol_) return; char ipStr[16]; formatDottedQuad(ipStr, ip); - socket_.connect(ipStr, ARTNET_PORT); + socket_.connect(ipStr, protocolPort(protocol)); std::memcpy(lastConnectedIp_, ip, 4); - } - - void sendUniverse(uint16_t universe, const uint8_t* data, uint16_t dataLen) { - uint8_t packet[ARTNET_HEADER_SIZE + MAX_CHANNELS_PER_UNIVERSE]; - size_t packetLen = buildPacket(packet, universe, sequence_, data, dataLen); - socket_.sendTo(packet, packetLen); + lastConnectedProtocol_ = protocol; } // Called off the hot path (onBuildState, onCorrectionChanged, setters) to diff --git a/src/light/drivers/ParallelLedDriver.h b/src/light/drivers/ParallelLedDriver.h new file mode 100644 index 00000000..e87f147e --- /dev/null +++ b/src/light/drivers/ParallelLedDriver.h @@ -0,0 +1,352 @@ +#pragma once + +#include "light/drivers/Drivers.h" // DriverBase, Correction +#include "light/drivers/LcdSlots.h" // encodeWs2812LcdSlots (shared encoder) +#include "light/drivers/LedDriverConfig.h" +#include "light/drivers/PinList.h" // parsePinList / assignCounts (shared) +#include "platform/platform.h" + +#include +#include // snprintf for status strings +#include // std::strcmp, std::memset + +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(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 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). + 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. + char pins[24] = ""; + char ledsPerPin[48] = ""; + + bool loopbackTest = false; + uint16_t loopbackRxPin = 0; + + void onBuildControls() override { + controls_.addText("pins", pins, sizeof(pins)); + controls_.addText("ledsPerPin", ledsPerPin, sizeof(ledsPerPin)); + derived()->addBusControls(); // i80 adds clockPin/dcPin here; Parlio none + controls_.addBool("loopbackTest", loopbackTest); + // Always bound, shown only in test mode — the conditional-control shape. + controls_.addUint16("loopbackRxPin", loopbackRxPin); + controls_.setHidden(controls_.count() - 1, !loopbackTest); + } + + bool controlChangeTriggersBuildState(const char* name) const override { + return std::strcmp(name, "pins") == 0 || std::strcmp(name, "ledsPerPin") == 0 + || derived()->busControlTriggersBuild(name); // clockPin/dcPin on i80 + } + + void onUpdate(const char* name) override { + const bool isTestControl = std::strcmp(name, "loopbackTest") == 0; + const bool isPinControl = std::strcmp(name, "pins") == 0 + || std::strcmp(name, "loopbackRxPin") == 0; + if (isTestControl && !loopbackTest) { + // Toggling the test off clears the loopback's own verdict, then + // re-derives the real driver status — a config/init error must + // survive, which a blind clearStatus() would hide. + clearFailBuf(); + clearStatus(); + parseConfig(); + reinit(); + } else if (loopbackTest && (isTestControl || isPinControl)) { + // A pin edit changes laneList_/laneCount_/frameBytes_, but onUpdate runs + // BEFORE the onBuildState() sweep (and loopbackRxPin doesn't trigger that + // sweep at all), so refresh the lane config here before testing it — + // otherwise the self-test would build its private bus from stale pins. + if (isPinControl) { parseConfig(); reinit(); } + runLoopbackSelfTest(); + } + } + + void setup() override { parseConfig(); reinit(); } + void teardown() override { + deinit(); + DriverBase::teardown(); // clears failBuf_ + configErr_ + } + + void onBuildState() override { + parseConfig(); + reinit(); + MoonModule::onBuildState(); + } + + // RGB<->RGBW changes the bytes-per-light and therefore the frame size. + void onCorrectionChanged() override { parseConfig(); reinit(); } + + void setSourceBuffer(Buffer* buf) override { sourceBuffer_ = buf; parseConfig(); } + void setCorrection(const Correction* c) override { correction_ = c; parseConfig(); } + + void loop() override { + if constexpr (Derived::lanesAvailable() == 0) return; // inert off this chip + if (!inited_ || !dmaBuf_ || !sourceBuffer_ || !sourceBuffer_->data() + || !correction_ || laneCount_ == 0 || maxLaneLights_ == 0) return; + const uint8_t outCh = correction_->outChannels; + if (outCh == 0 || frameBytes_ > derived()->busCapacity()) return; + + // Fused per-ROW pass: correct the same light index of every active lane + // into the wire block, then transpose it into 3-slot bus bytes. No heap, + // integer math only. + const uint8_t* src = sourceBuffer_->data(); + const uint8_t srcCh = sourceBuffer_->channelsPerLight(); + uint8_t* out = dmaBuf_; + uint8_t wire[kMaxLanes * 4]; + for (nrOfLightsType row = 0; row < maxLaneLights_; row++) { + uint8_t mask = 0; + for (uint8_t lane = 0; lane < laneCount_; lane++) { + if (row >= laneCounts_[lane]) continue; // short strand: idle LOW + mask |= static_cast(1u << lane); + correction_->apply(src + (laneStart_[lane] + row) * srcCh, + wire + lane * 4); + } + encodeWs2812LcdSlots(wire, mask, outCh, out); + out += static_cast(outCh) * 8 * 3; + } + // The latch pad after the rows is zeroed at reinit and never written + // here, so the transfer ends holding every lane LOW for >=300 µs. Wait + // only when the transfer actually started: a failed transmit gives no + // done-callback, so an unconditional wait would block the full 1000 ms + // timeout every tick. Drop the frame and retry next tick (self-heals). + if (derived()->busTransmit(frameBytes_)) + 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. + uint8_t laneCount() const { return laneCount_; } + nrOfLightsType laneLightCount(uint8_t i) const { return i < laneCount_ ? laneCounts_[i] : 0; } + nrOfLightsType laneStart(uint8_t i) const { return i < laneCount_ ? laneStart_[i] : 0; } + nrOfLightsType maxLaneLights() const { return maxLaneLights_; } + size_t frameBytes() const { return frameBytes_; } + +protected: + Derived* derived() { return static_cast(this); } + const Derived* derived() const { return static_cast(this); } + + Buffer* sourceBuffer_ = nullptr; + const Correction* correction_ = nullptr; + + LedDriverConfig cfg_; + bool inited_ = false; + uint8_t* dmaBuf_ = nullptr; // platform-owned; cached for the encode + uint16_t laneList_[kMaxLanes] = {}; + nrOfLightsType laneCounts_[kMaxLanes] = {}; + nrOfLightsType laneStart_[kMaxLanes] = {}; + uint8_t laneCount_ = 0; + nrOfLightsType maxLaneLights_ = 0; + size_t frameBytes_ = 0; + uint16_t busPins_[kMaxLanes] = {}; // data pins the live bus/unit was built + // with — a pin change must rebuild even + // when the buffer still fits + uint8_t busLaneCount_ = 0; // lane count the live bus was built with — + // a lane-count change (e.g. Parlio 8→4) + // can keep the same frameBytes_ yet needs + // a rebuild, so the fast path checks it too + + static constexpr uint8_t maxLanesForTarget() { + return (Derived::lanesAvailable() > 0 && Derived::lanesAvailable() < kMaxLanes) + ? Derived::lanesAvailable() + : kMaxLanes; + } + + // Frame bytes: longest lane × channels × 24 slot bytes, plus a zeroed latch + // pad of >=300 µs at the slot rate (800 B) with clock-tolerance slack (64), + // rounded up to the bus's 64-byte alignment. 0 when there's nothing to send. + static size_t frameBytesFor(nrOfLightsType maxLights, uint8_t outCh) { + if (maxLights == 0 || outCh == 0) return 0; + const size_t latchPad = 800 + 64; + const size_t bytes = static_cast(maxLights) * outCh * 24 + latchPad; + return (bytes + 63) & ~static_cast(63); + } + + // Re-derive lanes/counts/starts/frame size from the controls and the wired + // buffer/correction. Off the hot path; on error the driver idles with the + // parse literal in the status slot (same shape as RmtLedDriver). + bool parseConfig() { + laneCount_ = 0; + maxLaneLights_ = 0; + frameBytes_ = 0; + uint8_t n = 0; + const char* err = parsePinList(pins, laneList_, maxLanesForTarget(), n); + // i80 (kExactLaneCount) requires a real GPIO on every data line up to the + // bus width — a partial bus is rejected at esp_lcd_new_i80_bus(). Parlio + // accepts 1..8, so it sets kExactLaneCount=false and skips this. + if constexpr (Derived::kExactLaneCount) { + if (!err && n != kMaxLanes) err = "LCD bus needs exactly 8 pins"; + } + if (!err) { + const nrOfLightsType total = sourceBuffer_ ? sourceBuffer_->count() : 0; + err = assignCounts(ledsPerPin, n, total, laneCounts_); + } + if (err) { + setConfigErr(err); + return false; + } + laneCount_ = n; + nrOfLightsType start = 0; + for (uint8_t i = 0; i < laneCount_; i++) { + laneStart_[i] = start; + start = static_cast(start + laneCounts_[i]); + if (laneCounts_[i] > maxLaneLights_) maxLaneLights_ = laneCounts_[i]; + } + const uint8_t outCh = correction_ ? correction_->outChannels : 0; + frameBytes_ = frameBytesFor(maxLaneLights_, outCh); + clearConfigErr(); + return true; + } + + // --- bus + DMA buffer (hardware; this peripheral's targets only). Fused on + // purpose: max_transfer_bytes is fixed at bus creation, so growing the frame + // means re-creating the bus and its buffer together. Grow-only; the buffer is + // re-zeroed on every reinit so a shrunken frame's latch pad can't hold stale + // row bytes. --- + + void reinit() { + if constexpr (Derived::lanesAvailable() == 0) return; + if (laneCount_ == 0 || frameBytes_ == 0) { deinit(); return; } + if (inited_ && derived()->busCapacity() >= frameBytes_ && busPinsCurrent() + && busLaneCount_ == laneCount_) { + // Existing bus + buffer still fit AND sit on the wanted pins AND drive + // the same lane count — just clear stale bytes so the (possibly + // relocated) latch pad is zero. The pin check matters: a pin edit keeps + // the frame size, and without it the bus would keep clocking out on the + // OLD GPIOs. The lane-count check matters for Parlio: 8→4 lanes can keep + // frameBytes_ and the surviving pins, but the bus was built for 8. + std::memset(dmaBuf_, 0, derived()->busCapacity()); + return; + } + deinit(); + inited_ = derived()->busInit(frameBytes_); + dmaBuf_ = inited_ ? derived()->busBuffer() : nullptr; + if (inited_) { + for (uint8_t i = 0; i < kMaxLanes; i++) busPins_[i] = laneList_[i]; + busLaneCount_ = laneCount_; + derived()->recordBusPins(); // i80 also stores WR/DC; Parlio no-op + } + if (!inited_) { + clearFailBuf(); + setStatus(Derived::kInitFailMsg, Severity::Error); + } else if (status() == Derived::kInitFailMsg) { + clearStatus(); + } + } + + void deinit() { + if constexpr (Derived::lanesAvailable() == 0) return; + if (inited_) derived()->busDeinit(); + inited_ = false; + dmaBuf_ = nullptr; + busLaneCount_ = 0; + } + + bool busPinsCurrent() const { + for (uint8_t i = 0; i < laneCount_; i++) + if (busPins_[i] != laneList_[i]) return false; + return derived()->extraBusPinsCurrent(); // i80 also checks WR/DC + } + + // --- loopback self-test (control-driven; same status shapes as RMT). Builds + // the REAL frame with the test pattern on lane 0, deinits the live bus, and + // hands the platform a private TX path + RMT-RX capture that transmits the + // genuine frame back to back and verifies every bit. --- + + void runLoopbackSelfTest() { + if constexpr (Derived::lanesAvailable() == 0) { + clearFailBuf(); + setStatus("loopback: not supported on this platform", Severity::Warning); + return; + } + if (laneCount_ == 0) { + clearFailBuf(); + setStatus("loopback: no valid pins", Severity::Warning); + return; + } + const uint8_t outCh = correction_ ? correction_->outChannels : 0; + if (frameBytes_ == 0 || maxLaneLights_ == 0 || outCh == 0) { + clearFailBuf(); + setStatus("loopback: no lights to encode", Severity::Warning); + return; + } + // Build the REAL frame with the test pattern in every row on lane 0 only; + // the platform transmits the genuine transfer (size, DMA chain, latch pad) + // back to back and verifies every captured bit, so the test covers what + // the render loop actually sends. Heap alloc is fine: control-driven, off + // the hot path. + auto* frame = static_cast(platform::alloc(frameBytes_)); + if (!frame) { + clearFailBuf(); + setStatus("loopback: out of memory", Severity::Error); + return; + } + std::memset(frame, 0, frameBytes_); + uint8_t wire[kMaxLanes * 4] = {}; + wire[0] = 0xA5; wire[1] = 0x00; wire[2] = 0xFF; // wire[3] stays 0 (RGBW) + uint8_t* out = frame; + for (nrOfLightsType row = 0; row < maxLaneLights_; row++) { + encodeWs2812LcdSlots(wire, 0x01, outCh, out); + out += static_cast(outCh) * 8 * 3; + } + const size_t dataBytes = static_cast(maxLaneLights_) * outCh * 24; + deinit(); // free the live bus; the test builds its own on the data pins + const auto r = derived()->busLoopback(frame, frameBytes_, dataBytes, + static_cast(outCh * 8)); + platform::free(frame); + // Loopback result first, then reinit: if rebuilding the real bus fails + // afterwards, kInitFailMsg overwrites the verdict — an unusable driver + // matters more than a passed self-test. + if (!r.jumperDetected) { + clearFailBuf(); + setStatus("loopback: jumper not detected", Severity::Warning); + } else if (r.pass) { + clearFailBuf(); + setStatus("loopback PASS", Severity::Status); + } else if (failBufEnsure()) { + // Name the first corrupted light: the loopback reports the first + // mismatching bit; rowBits = outCh*8, so light = firstBadBit / rowBits. + const unsigned rowBits = static_cast(outCh) * 8u; + const unsigned badLight = rowBits ? r.firstBadBit / rowBits : 0u; + std::snprintf(failBuf_, kFailBufLen, + "loopback FAIL: bad bit %u/%u (light %u)", + static_cast(r.firstBadBit), + static_cast(r.bitsChecked), badLight); + setStatus(failBuf_, Severity::Error); + } else { + setStatus("loopback FAIL", Severity::Error); + } + reinit(); + } +}; + +} // namespace mm diff --git a/src/light/drivers/ParlioLedDriver.h b/src/light/drivers/ParlioLedDriver.h new file mode 100644 index 00000000..c3983272 --- /dev/null +++ b/src/light/drivers/ParlioLedDriver.h @@ -0,0 +1,77 @@ +#pragma once + +#include "light/drivers/ParallelLedDriver.h" // shared CRTP body +#include "platform/platform.h" + +#include + +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). +class ParlioLedDriver : public ParallelLedDriver { +public: + // All controls default to UNSET — pins="", ledsPerPin="" (= all lights on the + // first lane, even-split with one lane), loopbackRxPin=0 — so no constructor is + // needed (the base zero-initialises them). Pins/loopback are unset because the + // strand is user-soldered: a hard-coded pin would guess the user's wiring and + // could drive a pin committed elsewhere ("default only when it cannot do harm", + // see decisions.md). The P4-NANO bench used pins "20,21,22,23,24,25,26,27", + // loopbackRxPin 33 (clear of the NANO's strapping 34-38, Ethernet RMII + // 28-31/49-52, C6 SDIO 14-19/54, I2C 7-8 — clear GPIOs are 20-27, 32-33, 39-48). + + // --- CRTP hooks the base calls (all non-virtual; no vtable) --- + + 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"; + + // The WS2812 slot rate (375 ns @ 2.67 MHz) — identical to the LCD driver's; + // 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). + void addBusControls() {} + bool busControlTriggersBuild(const char*) const { return false; } + void recordBusPins() {} + bool extraBusPinsCurrent() const { return true; } + + bool busInit(size_t frameBytes) { + return platform::parlioWs2812Init(parlio_, laneList_, laneCount_, + kClockHz, frameBytes); + } + uint8_t* busBuffer() { return platform::parlioWs2812Buffer(parlio_); } + size_t busCapacity() const { return platform::parlioWs2812BufferCapacity(parlio_); } + bool busTransmit(size_t bytes) { return platform::parlioWs2812Transmit(parlio_, bytes); } + void busWait(uint32_t ms) { platform::parlioWs2812Wait(parlio_, ms); } + 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). + platform::RmtLoopbackResult busLoopback(const uint8_t* frame, size_t frameBytes, + size_t dataBytes, uint8_t rowBits) { + return platform::parlioWs2812Loopback(laneList_, laneCount_, loopbackRxPin, + frame, frameBytes, dataBytes, rowBits); + } + +private: + platform::ParlioWs2812Handle parlio_; +}; + +} // namespace mm diff --git a/src/light/drivers/PinList.h b/src/light/drivers/PinList.h new file mode 100644 index 00000000..c4144d7c --- /dev/null +++ b/src/light/drivers/PinList.h @@ -0,0 +1,77 @@ +#pragma once + +#include "light/light_types.h" // nrOfLightsType + +#include +#include // std::strtol + +namespace mm { + +// Shared pin/count list parsing for multi-output LED drivers — RmtLedDriver +// (one RMT channel per pin) and LcdLedDriver (one LCD_CAM lane per pin) both +// drive consecutive slices of the source buffer from the same two text +// controls, so the parsers live once here (extracted when the second user +// landed, per the increment-1 plan's "concrete first" rule). Both return +// nullptr on success or a static error literal the caller feeds straight into +// setStatus(); both are strtol-based like parseDottedQuad (Control.h) and +// fully host-testable (unit_RmtLedDriver_pins.cpp pins them for both drivers). + +// Parse "18,17,16" into out[0..maxPins). Spaces around tokens are fine +// (strtol skips them). Rejects empty input, bad tokens, trailing commas, +// duplicates, and more than maxPins entries (the chip's channel/lane cap). +inline const char* parsePinList(const char* s, uint16_t* out, uint8_t maxPins, + uint8_t& nOut) { + nOut = 0; + if (!s || !*s) return "invalid pin list"; + const char* p = s; + while (true) { + char* end = nullptr; + const long v = std::strtol(p, &end, 10); + if (end == p || v < 0 || v > 0xFFFF) return "invalid pin list"; + while (*end == ' ') end++; + if (nOut >= maxPins) return "too many pins for this chip"; + for (uint8_t i = 0; i < nOut; i++) + if (out[i] == static_cast(v)) return "duplicate pin"; + out[nOut++] = static_cast(v); + if (*end == '\0') return nullptr; + if (*end != ',') return "invalid pin list"; + p = end + 1; + } +} + +// Fill counts[0..nPins) from "100,100,50" (may be empty or shorter than the +// pin list; extra entries beyond nPins are ignored — a stale longer list +// after pins shrank is not an error). Explicit counts are clamped so the +// running sum never exceeds totalLights; the unassigned remainder splits +// evenly over the unlisted pins, last pin takes the rounding remainder. +inline const char* assignCounts(const char* s, uint8_t nPins, + nrOfLightsType totalLights, nrOfLightsType* counts) { + for (uint8_t i = 0; i < nPins; i++) counts[i] = 0; + nrOfLightsType remaining = totalLights; + uint8_t nExplicit = 0; + const char* p = s; + while (p && *p && nExplicit < nPins) { + char* end = nullptr; + const long v = std::strtol(p, &end, 10); + if (end == p || v < 0) return "invalid ledsPerPin list"; + while (*end == ' ') end++; + const nrOfLightsType c = + (v > static_cast(remaining)) ? remaining + : static_cast(v); + counts[nExplicit++] = c; + remaining = static_cast(remaining - c); + if (*end == '\0') break; + if (*end != ',') return "invalid ledsPerPin list"; + p = end + 1; + } + const uint8_t nRemaining = static_cast(nPins - nExplicit); + if (nRemaining > 0) { + const nrOfLightsType per = static_cast(remaining / nRemaining); + for (uint8_t i = nExplicit; i < nPins; i++) counts[i] = per; + counts[nPins - 1] = static_cast( + counts[nPins - 1] + (remaining - per * nRemaining)); + } + return nullptr; +} + +} // namespace mm diff --git a/src/light/drivers/RmtLedDriver.h b/src/light/drivers/RmtLedDriver.h new file mode 100644 index 00000000..d959cb9b --- /dev/null +++ b/src/light/drivers/RmtLedDriver.h @@ -0,0 +1,452 @@ +#pragma once + +#include "light/drivers/Drivers.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) +#include "platform/platform.h" + +#include +#include // snprintf for the loopback status string +#include // std::strcmp in onUpdate / controlChangeTriggersBuildState + +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. +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. + 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". + 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. + 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. + bool loopbackTest = false; // checkbox: on = run + keep re-running on change + uint16_t loopbackRxPin = 0; // jumper this to the first pin for the test + // (unset 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. + bool loopbackFrame = false; + + // 40 MHz RMT tick clock = 25 ns/tick: t0h 350ns→14, t1h 700ns→28, period + // 1250ns→50 ticks. The encoder converts ns→ticks via the granted resolution, + // so this is the requested clock, not a hard-coded tick count. + static constexpr uint32_t kResolutionHz = 40'000'000; + + // The pin/count list parsing (parsePinList / assignCounts) lives in + // PinList.h, shared with LcdLedDriver — both drivers slice the source + // buffer from the same two text controls. + + void onBuildControls() override { + controls_.addText("pins", pins, sizeof(pins)); + controls_.addText("ledsPerPin", ledsPerPin, sizeof(ledsPerPin)); + controls_.addBool("loopbackTest", loopbackTest); + // loopbackRxPin is always bound (so persistence can load it any time) but + // only shown while the test mode is on — same always-add-then-setHidden + // shape NetworkModule uses for its static-IP fields. The rebuild after + // every control change (HttpServerModule) re-runs this and flips the flag. + controls_.addUint16("loopbackRxPin", loopbackRxPin); + controls_.setHidden(controls_.count() - 1, !loopbackTest); + controls_.addBool("loopbackFrame", loopbackFrame); + 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. + bool controlChangeTriggersBuildState(const char* name) const override { + return std::strcmp(name, "pins") == 0 || std::strcmp(name, "ledsPerPin") == 0; + } + + // 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 + || std::strcmp(name, "loopbackRxPin") == 0 + || std::strcmp(name, "loopbackFrame") == 0; + if (isTestControl && !loopbackTest) { + // Toggling the test off clears the loopback verdict, then re-derives + // the real driver status — a config/init error must survive (a blind + // clearStatus() would hide it). + clearFailBuf(); + clearStatus(); + parseConfig(); + reinit(); + } else if (loopbackTest && (isTestControl || isPinControl)) { + // A `pins` edit changes pinList_/pinCount_, but onUpdate runs BEFORE the + // onBuildState() sweep re-parses (and loopbackRxPin/loopbackFrame don't + // trigger that sweep at all), so refresh here before testing — otherwise + // the self-test would transmit on the STALE pinList_[0] and show a verdict + // for the previous pin. Mirrors ParallelLedDriver::onUpdate. + if (std::strcmp(name, "pins") == 0) { parseConfig(); reinit(); } + runLoopbackSelfTest(); + } + } + + // 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(); } + 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. + void onBuildState() override { + parseConfig(); + resizeSymbols(); + reinit(); + 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. + void onCorrectionChanged() override { parseConfig(); resizeSymbols(); } + + void setSourceBuffer(Buffer* buf) override { + sourceBuffer_ = buf; + parseConfig(); // counts derive from the buffer's light count + resizeSymbols(); + } + void setCorrection(const Correction* c) override { + correction_ = c; + parseConfig(); // offsets derive from outChannels + resizeSymbols(); + } + + void loop() override { + if constexpr (platform::rmtTxChannels == 0) return; // inert off RMT chips + if (!inited_ || !sourceBuffer_ || !sourceBuffer_->data() || !correction_) return; + + const nrOfLightsType n = sourceBuffer_->count(); + const uint8_t outCh = correction_->outChannels; + // Same defensive guard ArtNet uses: skip rather than overrun if the + // symbol buffer is stale (e.g. correction swapped without a resize). + if (n == 0 || outCh == 0 || pinCount_ == 0 + || !symbols_ || symbolCap_ < symbolsFor(n, outCh)) return; + + // Fused single pass: correct one light into wire bytes, encode those + // bytes straight into the symbol buffer. No second sweep over encoded + // data, no per-light heap. + const uint8_t* src = sourceBuffer_->data(); + const uint8_t srcCh = sourceBuffer_->channelsPerLight(); + const uint16_t t0h = nsToTicks(cfg_.t0h_ns); + const uint16_t t1h = nsToTicks(cfg_.t1h_ns); + const uint16_t period = nsToTicks(cfg_.period_ns); + size_t s = 0; + uint8_t wire[4]; + for (nrOfLightsType i = 0; i < n; i++) { + correction_->apply(src + i * srcCh, wire); + encodeWs2812Symbols(wire, outCh, t0h, t1h, period, symbols_ + s); + s += static_cast(outCh) * 8; + } + // Start every pin's slice before waiting on any — the channels clock out + // concurrently, so the tick is charged the longest strand, not the sum. + // The shared reset gap (the WS2812 latch) runs once, after the last wait. + // Wait ONLY on channels whose transmit started: a failed transmit gives + // no done-callback, so waiting on it would block the full 1000 ms timeout + // and a single bad pin would stall the tick (the same guard the LCD / + // Parlio loops use, here per channel). + bool started[kMaxPins] = {}; + for (uint8_t i = 0; i < pinCount_; i++) { + if (pinCounts_[i] == 0) continue; + started[i] = platform::rmtWs2812Transmit(rmt_[i], symbols_ + pinOffsets_[i], + static_cast(pinCounts_[i]) * outCh * 8); + } + for (uint8_t i = 0; i < pinCount_; i++) { + if (started[i]) platform::rmtWs2812Wait(rmt_[i], 1000 /* ms */); + } + 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. + const uint32_t* symbolBuffer() const { return symbols_; } + size_t symbolCapacity() const { return symbolCap_; } + uint8_t pinCount() const { return pinCount_; } + nrOfLightsType pinLightCount(uint8_t i) const { return i < pinCount_ ? pinCounts_[i] : 0; } + size_t pinSymbolOffsetWords(uint8_t i) const { return i < pinCount_ ? pinOffsets_[i] : 0; } + +private: + // Source frame + shared correction — each physical driver owns these (the + // contract shares Correction::apply(), not the member storage); same shape + // as NetworkSendDriver. + Buffer* sourceBuffer_ = nullptr; + const Correction* correction_ = nullptr; + + LedDriverConfig cfg_; + platform::RmtWs2812Handle rmt_[kMaxPins]; + uint16_t pinList_[kMaxPins] = {}; // parsed pins, list order + nrOfLightsType pinCounts_[kMaxPins] = {}; // lights per pin (slice lengths) + size_t pinOffsets_[kMaxPins] = {}; // slice start in symbols_, words + uint8_t pinCount_ = 0; // 0 = idle (parse error / no pins) + bool inited_ = false; // all-or-nothing across the pins + uint32_t* symbols_ = nullptr; // owned; one word per WS2812 data bit + size_t symbolCap_ = 0; // words allocated + + // The parse-error literal currently shown in the status slot (nullptr when + // configErr_, failBuf_, kFailBufLen and the clearConfigErr/clearFailBuf/ + // failBufEnsure/setConfigErr helpers live on DriverBase (shared verbatim with + // the LCD and Parlio drivers). The on-demand FAIL string (failBuf_) is only + // formatted when a loopback or channel init FAILs; PASS/jumper/unsupported use + // flash literals. + + // The chip's TX-channel cap caps the pin list; on targets without RMT + // (desktop, where the constant is 0) fall back to kMaxPins so the parsing + // and slicing stay fully host-testable. + static constexpr uint8_t maxPinsForTarget() { + return (platform::rmtTxChannels > 0 && platform::rmtTxChannels < kMaxPins) + ? platform::rmtTxChannels + : kMaxPins; + } + + static size_t symbolsFor(nrOfLightsType lights, uint8_t channels) { + return static_cast(lights) * channels * 8; + } + + // Convert a ns duration to RMT ticks using the resolution the platform + // granted. Falls back to the requested clock when not inited (host/desktop). + uint16_t nsToTicks(uint32_t ns) const { + uint32_t hz = inited_ ? platform::rmtWs2812Resolution(rmt_[0]) : kResolutionHz; + if (hz == 0) hz = kResolutionHz; + return static_cast((static_cast(ns) * hz) / 1'000'000'000ull); + } + + // --- pin/count config (plain parsing; runs on every platform) --- + + // Re-derive pinList_/pinCounts_/pinOffsets_ from the two text controls and + // the current buffer/correction. On error: pinCount_ = 0 (loop() idles) and + // the static error literal goes to the status slot; a later successful parse + // clears it. Off the hot path. + bool parseConfig() { + pinCount_ = 0; + uint8_t n = 0; + const char* err = parsePinList(pins, pinList_, maxPinsForTarget(), n); + if (!err) { + const nrOfLightsType total = sourceBuffer_ ? sourceBuffer_->count() : 0; + err = assignCounts(ledsPerPin, n, total, pinCounts_); + } + if (err) { + setConfigErr(err); + return false; + } + pinCount_ = n; + const uint8_t outCh = correction_ ? correction_->outChannels : 0; + size_t off = 0; + for (uint8_t i = 0; i < pinCount_; i++) { + pinOffsets_[i] = off; + off += static_cast(pinCounts_[i]) * outCh * 8; + } + clearConfigErr(); + return true; + } + + // --- symbol buffer (plain heap; runs on every platform) --- + + // (Re)allocate the symbol buffer for the current source + correction. Off the + // hot path. Grows only — keeps a big-enough existing allocation. + void resizeSymbols() { + if (!sourceBuffer_ || !correction_) return; + const nrOfLightsType n = sourceBuffer_->count(); + const uint8_t ch = correction_->outChannels; + if (n == 0 || ch == 0) return; + const size_t need = symbolsFor(n, ch); + if (symbols_ && symbolCap_ >= need) return; + freeSymbols(); + symbols_ = static_cast(platform::alloc(need * sizeof(uint32_t))); + symbolCap_ = symbols_ ? need : 0; + } + + void freeSymbols() { + if (symbols_) { platform::free(symbols_); symbols_ = nullptr; symbolCap_ = 0; } + } + + // --- loopback self-test (control-driven) --- + + // Run the one-shot RMT TX→RX loopback on the FIRST pin and report via the + // MoonModule status slot. The slot stores a const char* (no copy), so PASS / + // jumper-missing / not-supported are flash literals — zero RAM. Only the + // FAIL case needs the captured hex, so it borrows a buffer allocated ON + // DEMAND and freed by clearFailBuf() (teardown + every non-FAIL outcome) — + // no permanent member. + void runLoopbackSelfTest() { + if constexpr (platform::rmtTxChannels == 0) { + clearFailBuf(); + setStatus("loopback: not supported on this platform", Severity::Warning); + return; + } + if (pinCount_ == 0) { + clearFailBuf(); + setStatus("loopback: no valid pins", Severity::Warning); + return; + } + // The test reconfigures the first data pin as TX, so release ALL our TX + // channels first — this also guarantees the test's RX channel can always + // allocate RMT memory, even with every TX channel otherwise claimed; + // reinit() restores them after. + deinitAll(); + const uint8_t txPin = static_cast(pinList_[0]); + const uint8_t rxPin = static_cast(loopbackRxPin); + platform::RmtLoopbackResult r; + if (loopbackFrame) { + // Whole-frame stress test on the first pin's slice (or 64 lights if + // no buffer is wired yet) — the size that actually exposes + // frame-rate / RF corruption. + const uint16_t lights = pinCounts_[0] > 0 + ? static_cast(pinCounts_[0]) : 64; + const uint8_t ch = correction_ ? correction_->outChannels : 3; + r = platform::rmtWs2812LoopbackFrame(txPin, rxPin, lights, ch); + } else { + r = platform::rmtWs2812Loopback(txPin, rxPin); + } + reinit(); + if (!r.jumperDetected) { + clearFailBuf(); + setStatus("loopback: jumper not detected", Severity::Warning); + } else if (r.pass) { + // PASS is a static literal (no failBuf_ alloc): setStatus holds the + // pointer, not a copy, so the string must outlive the call, and + // failBuf_ is by invariant a FAIL-only buffer (see clearFailBuf). + // The whole-frame bit count is in the serial log; the status slot + // doesn't need it badly enough to break either rule. + clearFailBuf(); + setStatus("loopback PASS", Severity::Status); + } else { + failBufEnsure(); + if (failBuf_ && loopbackFrame) { + // bits per light = outChannels × 8 (24 for RGB, 32 for RGBW) — + // the same channel count the frame was built with, not a + // hardcoded /24, so the light index is right for RGBW too. + const unsigned bitsPerLight = + (correction_ ? correction_->outChannels : 3u) * 8u; + std::snprintf(failBuf_, kFailBufLen, + "loopback FAIL: bad bit %u/%u (light %u)", + static_cast(r.firstBadBit), + static_cast(r.bitsChecked), + static_cast(r.firstBadBit / bitsPerLight)); + setStatus(failBuf_, Severity::Error); + } else if (failBuf_) { + std::snprintf(failBuf_, kFailBufLen, "loopback FAIL: sent %02X%02X%02X got %02X%02X%02X", + r.sent[0], r.sent[1], r.sent[2], r.got[0], r.got[1], r.got[2]); + setStatus(failBuf_, Severity::Error); + } else { + setStatus("loopback FAIL", Severity::Error); + } + } + } + + // --- RMT channels (hardware; RMT targets only) --- + + static constexpr const char* kInitFailMsg = "RMT init failed — check the pins"; + + // All-or-nothing: a failing pin deinits everything and reports which pin, + // so loop()'s guard stays a single bool and the user sees one clear error + // instead of some strands dark, some lit. + void reinit() { + if constexpr (platform::rmtTxChannels == 0) return; + deinitAll(); + if (pinCount_ == 0) return; // parse error — already in the status slot + for (uint8_t i = 0; i < pinCount_; i++) { + if (platform::rmtWs2812Init(rmt_[i], static_cast(pinList_[i]), + kResolutionHz, cfg_.invert)) continue; + // Surface which pin failed instead of silently no-op'ing in loop() — + // the status tells the user why output is dark (usually a bad pin), + // rather than leaving them to wonder why nothing lights. + deinitAll(); + clearFailBuf(); + if (failBufEnsure()) { + std::snprintf(failBuf_, kFailBufLen, "RMT init failed on pin %u", + static_cast(pinList_[i])); + setStatus(failBuf_, Severity::Error); + } else { + setStatus(kInitFailMsg, Severity::Error); + } + return; + } + inited_ = true; + // A prior init failure recovered (e.g. a pin fixed) — drop the stale error. + if (failBuf_ && status() == failBuf_) clearFailBuf(); + if (status() == kInitFailMsg) clearStatus(); + } + + // Releases only the RMT channels — NOT the symbol buffer (that's + // freeSymbols(), owned by teardown). reinit() calls this on every rebuild, + // so freeing the buffer here would strand loop() — the original bug. + void deinitAll() { + if constexpr (platform::rmtTxChannels == 0) return; + for (uint8_t i = 0; i < kMaxPins; i++) { + if (rmt_[i].impl) platform::rmtWs2812Deinit(rmt_[i]); + } + inited_ = false; + } +}; + +} // namespace mm diff --git a/src/light/drivers/RmtSymbol.h b/src/light/drivers/RmtSymbol.h new file mode 100644 index 00000000..671f3766 --- /dev/null +++ b/src/light/drivers/RmtSymbol.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include + +namespace mm { + +// Encode wire-ordered LED bytes into ESP32 RMT symbols — domain logic, no ESP +// header, so it is host-testable without an ESP32 (the platform owns only the +// peripheral that consumes these symbols; see platform.h rmtWs2812*). +// +// RMT symbol layout (matches ESP-IDF's rmt_symbol_word_t, documented here so no +// driver/rmt_*.h leaks into src/light/): one 32-bit word is two 16-bit halves, +// each a (duration:15, level:1) pair with the level in bit 15: +// +// bits 0..14 : duration0 (RMT ticks) bit 15 : level0 (0 or 1) +// bits 16..30 : duration1 (RMT ticks) bit 31 : level1 (0 or 1) +// +// One WS2812 data bit = one symbol: HIGH for t?hTicks, then LOW for the rest of +// the cell. So half0 = (t?hTicks, level 1), half1 = (period - t?h, level 0). +constexpr uint32_t makeRmtSymbol(uint16_t dur0, uint8_t lvl0, + uint16_t dur1, uint8_t lvl1) { + return (static_cast(dur0 & 0x7FFF)) + | (static_cast(lvl0 & 1) << 15) + | (static_cast(dur1 & 0x7FFF) << 16) + | (static_cast(lvl1 & 1) << 31); +} + +// Encode one light's already-wire-ordered bytes (`channels` of them — brightness, +// GRB reorder and any RGBW white have ALREADY been applied by Correction) into +// `channels * 8` RMT symbols at `out`, MSB-first within each byte. Each data bit +// becomes one symbol: HIGH for t1hTicks (a 1) or t0hTicks (a 0), then LOW for +// (periodTicks - that high time). Durations are in RMT ticks (the caller converts +// ns→ticks from the peripheral's granted resolution). `out` must hold at least +// channels*8 words. +// +// Header-only inline (light domain is header-only; see coding-standards.md). The +// host encoder test asserts this contract: GRB order via the corrected input, +// MSB-first, exact high/low ticks per bit. Implemented in Phase C. +inline void encodeWs2812Symbols(const uint8_t* wire, uint8_t channels, + uint16_t t0hTicks, uint16_t t1hTicks, + uint16_t periodTicks, uint32_t* out) { + const uint16_t t0Low = static_cast(periodTicks - t0hTicks); + const uint16_t t1Low = static_cast(periodTicks - t1hTicks); + // Pre-build the two possible symbols once; each data bit picks one. + const uint32_t sym0 = makeRmtSymbol(t0hTicks, 1, t0Low, 0); + const uint32_t sym1 = makeRmtSymbol(t1hTicks, 1, t1Low, 0); + size_t s = 0; + for (uint8_t ch = 0; ch < channels; ch++) { + const uint8_t byte = wire[ch]; + for (int bit = 7; bit >= 0; bit--) { // MSB-first within the byte + out[s++] = (byte & (1u << bit)) ? sym1 : sym0; + } + } +} + +} // namespace mm diff --git a/src/light/effects/AudioSpectrumEffect.h b/src/light/effects/AudioSpectrumEffect.h new file mode 100644 index 00000000..1c60c24c --- /dev/null +++ b/src/light/effects/AudioSpectrumEffect.h @@ -0,0 +1,129 @@ +#pragma once + +#include "light/layers/Layer.h" +#include "core/color.h" // hsvToRgb, RGB +#include "core/AudioModule.h" // AudioModule::latestFrame() + +#include // std::memset + +namespace mm { + +// Audio-reactive spectrum analyser: the classic equalizer display. The 16 +// frequency bands (AudioFrame::bands, bass→treble) spread across the grid's X +// axis, each column lit from the bottom up in proportion to its band's magnitude. +// The 16 bands scale to any width: a 32-wide grid gives each band two columns, an +// 8-wide grid samples every other band, a 16-wide grid is one column per band. +// On a grid at least 3 rows tall, the bottom row is an overall level/volume meter +// (a horizontal VU bar) and the bars sit above it; shorter grids use the full +// height for the spectrum. +// +// Reads the live frame from AudioModule::latestFrame(); no mic / silence → all +// 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. +class AudioSpectrumEffect : public EffectBase { +public: + const char* tags() const override { return "📊"; } + + // 0 = height gradient (green base → red top, the VU look); 1 = per-band hue + // (each column its own colour across the spectrum, the rainbow analyser look). + uint8_t colorMode = 0; + + void onBuildControls() override { + static constexpr const char* kColorOptions[] = {"height", "per-band"}; + controls_.addSelect("colorMode", colorMode, kColorOptions, 2); + } + + void loop() override { + uint8_t* buf = buffer(); + const lengthType w = width(); + const lengthType h = height(); + const lengthType d = depth(); + const uint8_t cpl = channelsPerLight(); + if (w == 0 || h == 0 || d == 0 || cpl == 0) return; + + std::memset(buf, 0, static_cast(w) * h * d * cpl); + + const AudioFrame* f = AudioModule::latestFrame(); + + auto setRGB = [&](lengthType x, lengthType y, lengthType z, + uint8_t r, uint8_t g, uint8_t b) { + size_t off = (static_cast(z) * h * w + + static_cast(y) * w + x) * cpl; + if (cpl >= 1) buf[off + 0] = r; + if (cpl >= 2) buf[off + 1] = g; + if (cpl >= 3) buf[off + 2] = b; + }; + + // On a grid tall enough (h >= 3), reserve the BOTTOM row for an overall + // level/volume meter — a horizontal bar lit left-to-right in proportion to + // `level` — and draw the spectrum bars in the rows ABOVE it. On a short + // grid (h < 3) there's no room to spare, so the whole height is spectrum. + const bool levelRow = (h >= 3); + const lengthType specH = levelRow ? static_cast(h - 1) : h; + + if (levelRow) { + const lengthType y = static_cast(h - 1); // bottom row + const lengthType litW = static_cast( + static_cast(f->level > 255 ? 255 : f->level) * w / 255u); + for (lengthType x = 0; x < litW; x++) { + // Green → red across the width, the VU-meter look. + const uint8_t frac = static_cast( + static_cast(x) * 255u / (w > 1 ? w : 1)); + for (lengthType z = 0; z < d; z++) + setRGB(x, y, z, frac, static_cast(255 - frac), 0); + } + } + + for (lengthType x = 0; x < w; x++) { + // Map this column onto one of the 16 bands (scales to any width). + const uint8_t band = static_cast( + static_cast(x) * 16u / static_cast(w)); + const uint8_t mag = f->bands[band]; // 0..255 + + // Bar height over the SPECTRUM area (above the level row): magnitude → + // lit rows from the bottom of that area up. h==1 lights the one row. + const lengthType lit = (h == 1) + ? 1 + : static_cast(static_cast(mag) * specH / 255u); + + // Per-band hue: spread the 16 bands across the full colour wheel so + // each column is a distinct colour (bass red → treble violet). + const uint8_t bandHue = static_cast(band * 16); + + // Spectrum bars sit ABOVE the level row: their bottom is row h-2 when a + // level row is reserved, else h-1 (the grid bottom). + const lengthType specBottom = static_cast(levelRow ? h - 2 : h - 1); + for (lengthType row = 0; row < lit; row++) { + const lengthType y = static_cast(specBottom - row); + + uint8_t r, g, b; + if (colorMode == 1) { + // Per-band: the column's hue at full brightness (a strip dims + // its single row by magnitude instead). + const uint8_t v = (h == 1) ? mag : 255; + const RGB c = hsvToRgb(bandHue, 255, v); + r = c.r; g = c.g; b = c.b; + } else { + // Height gradient: green at the base → red at the top. The + // gradient runs over the spectrum's own rows (specH), not the + // full grid height: when the bottom row is reserved as the level + // meter, specH == h-1, so dividing by (h-1) would stop the top + // spectrum row one step short of full red. On a 1D strip + // (specH <= 1) there's no height to gradient over, so the one + // row is a green→red ramp driven by magnitude (dark at mag 0). + const uint8_t frac = (specH > 1) + ? static_cast(static_cast(row) * 255u / (specH - 1)) + : mag; + r = (specH > 1) ? frac : static_cast(static_cast(frac) * mag / 255u); + g = (specH > 1) ? static_cast(255 - frac) + : static_cast(static_cast(255 - frac) * mag / 255u); + b = 0; + } + for (lengthType z = 0; z < d; z++) + setRGB(x, y, z, r, g, b); + } + } + } +}; + +} // namespace mm diff --git a/src/light/effects/AudioVolumeEffect.h b/src/light/effects/AudioVolumeEffect.h new file mode 100644 index 00000000..db2ade01 --- /dev/null +++ b/src/light/effects/AudioVolumeEffect.h @@ -0,0 +1,57 @@ +#pragma once + +#include "light/layers/Layer.h" +#include "core/AudioModule.h" // AudioModule::latestFrame() + +namespace mm { + +// Audio-reactive VU effect: the whole grid pulses with the microphone's sound +// level. The simplest audio consumer — one scalar (AudioFrame::level) drives a +// single brightness, a colour shifting from calm to hot as it rises. Reads the +// 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. +class AudioVolumeEffect : public EffectBase { +public: + const char* tags() const override { return "🔊"; } + + uint8_t brightness = 255; // overall ceiling + + void onBuildControls() override { + controls_.addUint8("brightness", brightness, 1, 255); + } + + void loop() override { + uint8_t* buf = buffer(); + const lengthType w = width(); + const lengthType h = height(); + const lengthType d = depth(); + const uint8_t cpl = channelsPerLight(); + const size_t total = static_cast(w) * h * d * cpl; + + // level is ~0..255; scale to the brightness ceiling. + const uint16_t level = AudioModule::latestFrame()->level; + const uint16_t v = static_cast( + (level > 255 ? 255 : level) * brightness / 255); + + // A simple level-driven colour ramp: green (quiet) → red (loud), with v + // setting overall intensity. Fill every light identically — a VU meter on + // the whole surface; modifiers/layouts give it shape. + const uint8_t r = static_cast(v); + const uint8_t g = static_cast(v > 128 ? (255 - v) * 2 : 255 * v / 128); + const uint8_t b = 0; + + // Write logical RGB only: channel order and any RGBW white are the + // driver's Correction (W = min(r,g,b)), like every other effect. Effect + // buffers are RGB (cpl == 3) today; the loop stays correct for any cpl by + // writing R/G/B where they fit and zeroing any extra channels, so no stale + // bytes survive from a prior, differently-shaped frame. + for (size_t off = 0; off < total; off += cpl) { + if (cpl >= 1) buf[off + 0] = r; + if (cpl >= 2) buf[off + 1] = g; + if (cpl >= 3) buf[off + 2] = b; + for (uint8_t ch = 3; ch < cpl; ch++) buf[off + ch] = 0; + } + } +}; + +} // namespace mm diff --git a/src/light/effects/NetworkReceiveEffect.h b/src/light/effects/NetworkReceiveEffect.h new file mode 100644 index 00000000..6509e410 --- /dev/null +++ b/src/light/effects/NetworkReceiveEffect.h @@ -0,0 +1,222 @@ +#pragma once + +#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/layers/Layer.h" +#include "platform/platform.h" + +#include + +namespace mm { + +// Lights-over-UDP receiver as an EFFECT: external light data is just another +// module that writes into the layer buffer, composable with modifiers and +// blending like any generated effect. The end-to-end pair with +// NetworkSendDriver — and the receive side for industry senders (Resolume, +// Madrix, xLights, LedFx, …). +// +// All three protocols are received AT ONCE: the effect binds the three +// well-known ports (ArtNet 6454, E1.31 5568, DDP 4048) and validates each +// packet against its port's wire format — WLED's multi-port pattern. There is +// deliberately no protocol control: whatever a sender speaks just works, and +// the status field shows what is being received. +// +// ArtNet discovery: controllers (Resolume's Advanced Output, Madrix, xLights) +// find nodes by broadcasting ArtPoll; this effect answers with ArtPollReply so +// the device appears in their node lists instead of needing manual IP entry. +// +// The layer clears its buffer at the start of every tick, so packets are +// drained into an owned STAGING buffer and staging is copied to the layer +// buffer each tick — hold-last-frame semantics; without it the lights would +// strobe black between frames. The drain is non-blocking and bounded per tick +// (network input is synchronous at the frame boundary — the architecture.md +// rule), so a packet flood can't wedge the render loop. Sequence fields (and +// DDP's push flag) are ignored: last write wins into staging. +// +// Prior art: MoonLight's D_NetworkIn (single node, three protocols), WLED's +// realtime UDP input (multi-port + per-packet validation, ArtPollReply), and +// projectMM v1's ArtNetInModule. +class NetworkReceiveEffect : public EffectBase { +public: + const char* tags() const override { return "📡🌙"; } // network input · MoonLight / v1 lineage + + uint16_t universeStart = 0; // mirrors the sender's universe_start (ArtNet/E1.31) + // Bytes each universe maps to in the buffer. 510 = whole RGB lights per + // universe (the xLights/Falcon convention and our sender's split); set 512 + // for senders that pack pixels across universe boundaries (Madrix-style). + // Also clamps the copied payload, so a 512-channel frame from a 510-packed + // source can't bleed its 2 padding bytes into the next universe's data. + uint16_t channelsPerUniverse = static_cast(MAX_CHANNELS_PER_UNIVERSE); + + void onBuildControls() override { + controls_.addUint16("universe_start", universeStart); + controls_.addUint16("channels_per_universe", channelsPerUniverse); + } + + void setup() override { + // One socket per protocol port, all listening at once. Each bind is + // attempted independently so one taken port can't stop the others from + // draining; any failure is reported once. + const bool artnetOk = artnetSocket_.open() && artnetSocket_.bind(ARTNET_PORT); + const bool e131Ok = e131Socket_.open() && e131Socket_.bind(E131_PORT); + const bool ddpOk = ddpSocket_.open() && ddpSocket_.bind(DDP_PORT); + if (artnetOk && e131Ok && ddpOk) { + if (status() == kBindFailMsg) clearStatus(); + } else { + setStatus(kBindFailMsg, Severity::Error); + } + } + + void teardown() override { + artnetSocket_.close(); + e131Socket_.close(); + ddpSocket_.close(); + releaseStaging(); + setDynamicBytes(0); + clearStatus(); + } + + ~NetworkReceiveEffect() override { releaseStaging(); } + + // Size staging to the layer buffer (one byte per channel byte), off the + // hot path — the GameOfLifeEffect resource shape. Zeroed on (re)alloc so a + // fresh grid starts dark, not with stale bytes. + void onBuildState() override { + const size_t need = static_cast(nrOfLights()) * channelsPerLight(); + if (enabled() && need > 0) { + if (need != stagingBytes_) { + releaseStaging(); + staging_ = static_cast(platform::alloc(need)); + if (staging_) { + stagingBytes_ = need; + std::memset(staging_, 0, need); + } + } + } else { + releaseStaging(); + } + setDynamicBytes(stagingBytes_); + } + + void loop() override { + if (!staging_) return; + // Bounded non-blocking drain per socket: 128 packets ≈ one full ArtNet + // frame for ~21k RGB lights; a flood costs at most 3×128 recvfrom calls + // per tick, then the rest waits in the socket buffers for the next tick. + uint16_t universe = 0, dataLen = 0; + uint32_t byteOffset = 0; + const uint8_t* data = nullptr; + uint8_t srcIp[4]; + for (int i = 0; i < kMaxPacketsPerTick; i++) { + const int n = artnetSocket_.recvFrom(pkt_, sizeof(pkt_), srcIp); + if (n <= 0) break; + if (parseArtDmxPacket(pkt_, static_cast(n), universe, data, dataLen)) { + applyDmx(universe, data, dataLen); + noteReceiving(kStatusArtnet); + } else if (isArtPoll(pkt_, static_cast(n))) { + replyToPoll(srcIp); // make the device show up in controller node lists + } + } + for (int i = 0; i < kMaxPacketsPerTick; i++) { + const int n = e131Socket_.recvFrom(pkt_, sizeof(pkt_)); + if (n <= 0) break; + if (parseE131Packet(pkt_, static_cast(n), universe, data, dataLen)) { + applyDmx(universe, data, dataLen); + noteReceiving(kStatusE131); + } + } + for (int i = 0; i < kMaxPacketsPerTick; i++) { + const int n = ddpSocket_.recvFrom(pkt_, sizeof(pkt_)); + if (n <= 0) break; + if (parseDdpPacket(pkt_, static_cast(n), byteOffset, data, dataLen)) { + applyBytes(byteOffset, data, dataLen); + noteReceiving(kStatusDdp); + } + } + // Staging → layer buffer (the layer cleared it at tick start). + uint8_t* buf = buffer(); + if (!buf) return; + const size_t bufBytes = static_cast(nrOfLights()) * channelsPerLight(); + std::memcpy(buf, staging_, stagingBytes_ < bufBytes ? stagingBytes_ : bufBytes); + } + + // Place one universe's payload: byte offset (universe − universeStart) × + // channels_per_universe, payload clamped to one universe's stride so a + // 512-channel frame can't bleed past its slot. Universes below the start or + // beyond the buffer are ignored. Shared by ArtNet and E1.31; DDP skips the + // universe math and calls applyBytes directly. Public for testability (the + // buildArtDmxPacket precedent). + void applyDmx(uint16_t universe, const uint8_t* data, uint16_t len) { + if (universe < universeStart || channelsPerUniverse == 0) return; + if (len > channelsPerUniverse) len = channelsPerUniverse; + applyBytes(static_cast(universe - universeStart) * channelsPerUniverse, + data, len); + } + + // The one clamped write into staging (DDP's native addressing). The bound + // check runs BEFORE any addition so a hostile 32-bit offset can't overflow + // past it. + void applyBytes(size_t offset, const uint8_t* data, uint16_t len) { + if (!staging_ || offset >= stagingBytes_) return; + size_t n = len; + if (offset + n > stagingBytes_) n = stagingBytes_ - offset; + std::memcpy(staging_ + offset, data, n); + } + + // Test-only accessors — let the unit tests pin the staging lifecycle + // (sized off the hot path, never reallocated by loop, freed on teardown). + const uint8_t* stagingData() const { return staging_; } + size_t stagingBytes() const { return stagingBytes_; } + +private: + static constexpr int kMaxPacketsPerTick = 128; + static constexpr const char* kBindFailMsg = "UDP bind failed — port in use?"; + static constexpr const char* kStatusArtnet = "receiving Art-Net"; + static constexpr const char* kStatusE131 = "receiving E1.31"; + static constexpr const char* kStatusDdp = "receiving DDP"; + + platform::UdpSocket artnetSocket_; + platform::UdpSocket e131Socket_; + platform::UdpSocket ddpSocket_; + uint8_t pkt_[1500] = {}; // one datagram, any protocol (DDP max 1450) + uint8_t* staging_ = nullptr; // owned; layer-buffer-sized + size_t stagingBytes_ = 0; + + // Swap the "receiving " diagnostic — but never clobber a bind + // error. Pointer compares only (all four strings are static literals). + void noteReceiving(const char* lit) { + const char* s = status(); + if (s == nullptr || s == kStatusArtnet || s == kStatusE131 || s == kStatusDdp) { + if (s != lit) setStatus(lit, Severity::Status); + } + } + + // Answer an ArtPoll with our IP/MAC/name so controllers list the device. + // Runs at most once per poll (controllers poll every few seconds) — the + // 239-byte reply lives on the stack, no allocation. + void replyToPoll(const uint8_t pollerIp[4]) { + char ipStr[16] = {}; + platform::ethGetIP(ipStr, sizeof(ipStr)); + if (!ipStr[0]) platform::wifiStaGetIP(ipStr, sizeof(ipStr)); + if (!ipStr[0]) std::strncpy(ipStr, platform::hostIp(), sizeof(ipStr) - 1); + uint8_t myIp[4]; + if (!parseDottedQuad(ipStr, myIp)) return; // no usable IP — stay silent + uint8_t mac[6]; + platform::getMacAddress(mac); + uint8_t reply[ARTNET_POLL_REPLY_SIZE]; + buildArtPollReply(reply, myIp, mac, "projectMM", "projectMM NetworkReceive", + universeStart); + artnetSocket_.sendToAddr(pollerIp, ARTNET_PORT, reply, sizeof(reply)); + } + + void releaseStaging() { + if (staging_) { + platform::free(staging_); + staging_ = nullptr; + stagingBytes_ = 0; + } + } +}; + +} // namespace mm diff --git a/src/light/effects/RingsEffect.h b/src/light/effects/RingsEffect.h new file mode 100644 index 00000000..4ed648f9 --- /dev/null +++ b/src/light/effects/RingsEffect.h @@ -0,0 +1,120 @@ +#pragma once + +#include "light/layers/Layer.h" +#include "core/color.h" + +namespace mm { + +// Expanding concentric rings from random centre points. +// Each ring grows continuously and respawns at a fresh random +// position once it leaves the visible area. Multiple rings overlap. +// (Renamed from RipplesEffect: the Ripples name now holds the MoonLight +// sine-wave water-surface port; this concentric-rings effect is Rings.) +class RingsEffect : public EffectBase { +public: + const char* tags() const override { return "💫🦅"; } // MoonLight origin · David Jupijn / Rising Step + // Iterates y and x only; Layer::extrude fills z on 3D layers. + Dim dimensions() const override { return Dim::D2; } + + static constexpr uint8_t MAX_RIPPLES = 8; + + uint8_t count = 4; + uint8_t speed = 60; + uint8_t thickness = 3; + uint8_t hue_shift = 0; + + void onBuildControls() override { + controls_.addUint8("count", count, 1, 255); + controls_.addUint8("speed", speed, 1, 255); + controls_.addUint8("thickness", thickness, 1, 255); + controls_.addUint8("hue_shift", hue_shift, 0, 255); + } + + void loop() override { + uint8_t* buf = buffer(); + lengthType w = width(); + lengthType h = height(); + uint8_t cpl = channelsPerLight(); + if (w <= 0 || h <= 0) return; + + // Visible radius limit (octagonal distance to far corner) + uint8_t maxR = dist8(static_cast(w), static_cast(h)); + + if (!initialized_) { + for (uint8_t i = 0; i < MAX_RIPPLES; i++) { + spawn(i, w, h); + // Stagger initial radii so ripples are spread across all sizes + radius_[i] = static_cast((i * maxR) / MAX_RIPPLES); + } + initialized_ = true; + } + + uint32_t now = elapsed(); + uint32_t dt = now - lastElapsed_; + lastElapsed_ = now; + // growth in radius units per frame (scaled by speed control + dt) + uint16_t growth = static_cast((static_cast(speed) * dt) >> 7); + if (growth == 0) growth = 1; + + for (uint8_t i = 0; i < count && i < MAX_RIPPLES; i++) { + uint16_t next = static_cast(radius_[i]) + growth; + if (next > maxR) { + spawn(i, w, h); + } else { + radius_[i] = static_cast(next); + } + } + + for (lengthType y = 0; y < h; y++) { + uint8_t* row = buf + static_cast(y) * static_cast(w) * cpl; + for (lengthType x = 0; x < w; x++) { + uint16_t r_acc = 0, g_acc = 0, b_acc = 0; + for (uint8_t i = 0; i < count && i < MAX_RIPPLES; i++) { + int16_t dx = static_cast(x - cx_[i]); + int16_t dy = static_cast(y - cy_[i]); + uint8_t d = dist8(dx, dy); + int16_t diff = static_cast(d) - static_cast(radius_[i]); + if (diff < 0) diff = static_cast(-diff); + if (diff < thickness) { + // Brightness peaks at ring centre, falls off with distance from ring. + uint8_t falloff = static_cast(((thickness - diff) * 255) / thickness); + // Older ripples (large radius) fade out. + uint8_t age_fade = static_cast(255 - ((radius_[i] * 255u) / maxR)); + uint8_t intensity = scale8(falloff, age_fade); + RGB c = hsvToRgb(static_cast(hue_[i] + hue_shift), 240, intensity); + r_acc = static_cast(r_acc + c.r); + g_acc = static_cast(g_acc + c.g); + b_acc = static_cast(b_acc + c.b); + } + } + if (cpl >= 1) row[0] = r_acc > 255 ? 255 : static_cast(r_acc); + if (cpl >= 2) row[1] = g_acc > 255 ? 255 : static_cast(g_acc); + if (cpl >= 3) row[2] = b_acc > 255 ? 255 : static_cast(b_acc); + row += cpl; + } + } + } + +private: + lengthType cx_[MAX_RIPPLES] = {}; + lengthType cy_[MAX_RIPPLES] = {}; + uint8_t radius_[MAX_RIPPLES] = {}; + uint8_t hue_[MAX_RIPPLES] = {}; + bool initialized_ = false; + uint32_t lastElapsed_ = 0; + uint32_t rngState_ = 0xC0DECAFEu; + + uint8_t rand8() { + rngState_ = rngState_ * 1103515245u + 12345u; + return static_cast((rngState_ >> 16) & 0xFF); + } + + void spawn(uint8_t i, lengthType w, lengthType h) { + cx_[i] = static_cast((static_cast(rand8()) * w) >> 8); + cy_[i] = static_cast((static_cast(rand8()) * h) >> 8); + radius_[i] = 0; + hue_[i] = rand8(); + } +}; + +} // namespace mm diff --git a/src/light/effects/RipplesEffect.h b/src/light/effects/RipplesEffect.h index 5765b1a7..e1b26f35 100644 --- a/src/light/effects/RipplesEffect.h +++ b/src/light/effects/RipplesEffect.h @@ -3,116 +3,88 @@ #include "light/layers/Layer.h" #include "core/color.h" +#include +#include + namespace mm { -// Expanding concentric rings from random centre points. -// Each ripple grows continuously and respawns at a fresh random -// position once it leaves the visible area. Multiple ripples overlap. +// 3D dancing sine-wave ripples — a reimplementation of MoonLight's Ripples +// against our architecture (studied, not copied; see docs/history). +// +// For each (x, z) column on the floor plane, the distance from the centre sets a +// wave phase; one pixel per column is lit at the height +// y = floor(h/2 * (1 + sin(dist / interval + time))) +// so the lit surface ripples like water filling the 3D volume. The hue cycles +// over time and position (a palette substitute). Genuinely 3D: it writes a +// height across the y-axis, so it needs real depth — on a 2D grid (depth 1) it +// degenerates to a single y-row, which is honest for a flat layout. +// +// Float trig in the loop matches the existing wave effects (Plasma, LavaLamp); +// the hot-path integer-math preference is for per-light colour work, not the +// handful of transcendental ops a wave front needs. class RipplesEffect : public EffectBase { public: - const char* tags() const override { return "💫🦅"; } // MoonLight origin · David Jupijn / Rising Step - // Iterates y and x only; Layer::extrude fills z on 3D layers. - Dim dimensions() const override { return Dim::D2; } - - static constexpr uint8_t MAX_RIPPLES = 8; + const char* tags() const override { return "💫🟦🦅"; } // MoonLight origin · water-ripple + Dim dimensions() const override { return Dim::D3; } - uint8_t count = 4; - uint8_t speed = 60; - uint8_t thickness = 3; - uint8_t hue_shift = 0; + uint8_t speed = 50; // 0 = stopped, 99 = fast + uint8_t interval = 128; // wavefront spacing: low = tight rings, high = wide void onBuildControls() override { - controls_.addUint8("count", count, 1, 255); - controls_.addUint8("speed", speed, 1, 255); - controls_.addUint8("thickness", thickness, 1, 255); - controls_.addUint8("hue_shift", hue_shift, 0, 255); + controls_.addUint8("speed", speed, 0, 99); + controls_.addUint8("interval", interval, 1, 254); } void loop() override { uint8_t* buf = buffer(); - lengthType w = width(); - lengthType h = height(); - uint8_t cpl = channelsPerLight(); - if (w <= 0 || h <= 0) return; - - // Visible radius limit (octagonal distance to far corner) - uint8_t maxR = dist8(static_cast(w), static_cast(h)); - - if (!initialized_) { - for (uint8_t i = 0; i < MAX_RIPPLES; i++) { - spawn(i, w, h); - // Stagger initial radii so ripples are spread across all sizes - radius_[i] = static_cast((i * maxR) / MAX_RIPPLES); - } - initialized_ = true; - } - - uint32_t now = elapsed(); - uint32_t dt = now - lastElapsed_; - lastElapsed_ = now; - // growth in radius units per frame (scaled by speed control + dt) - uint16_t growth = static_cast((static_cast(speed) * dt) >> 7); - if (growth == 0) growth = 1; - - for (uint8_t i = 0; i < count && i < MAX_RIPPLES; i++) { - uint16_t next = static_cast(radius_[i]) + growth; - if (next > maxR) { - spawn(i, w, h); - } else { - radius_[i] = static_cast(next); - } - } - - for (lengthType y = 0; y < h; y++) { - uint8_t* row = buf + static_cast(y) * static_cast(w) * cpl; + const lengthType w = width(); + const lengthType h = height(); + const lengthType d = depth(); + const uint8_t cpl = channelsPerLight(); + if (w <= 0 || h <= 0 || d <= 0) return; + + // Clear: every column lights at most one y, so the rest must be black. + std::memset(buf, 0, static_cast(nrOfLights()) * cpl); + + // Wavefront spacing, scaled to the layout height so the look is grid-size + // independent. Guard against a degenerate (near-zero) spacing. + const float rippleInterval = 1.3f * ((255.0f - static_cast(interval)) / 128.0f) + * std::sqrt(static_cast(h)); + if (rippleInterval < 0.01f) return; + + // Animate. speed 99 → fast, 0 → frozen. elapsed() is ms since effect start. + const float timeInterval = static_cast(elapsed()) + / (100.0f - static_cast(speed)) / 6.4f; + + const float cx = static_cast(w - 1) / 2.0f; + const float cz = static_cast(d - 1) / 2.0f; + const nrOfLightsType wh = static_cast(w) * h; + + for (lengthType z = 0; z < d; z++) { for (lengthType x = 0; x < w; x++) { - uint16_t r_acc = 0, g_acc = 0, b_acc = 0; - for (uint8_t i = 0; i < count && i < MAX_RIPPLES; i++) { - int16_t dx = static_cast(x - cx_[i]); - int16_t dy = static_cast(y - cy_[i]); - uint8_t d = dist8(dx, dy); - int16_t diff = static_cast(d) - static_cast(radius_[i]); - if (diff < 0) diff = static_cast(-diff); - if (diff < thickness) { - // Brightness peaks at ring centre, falls off with distance from ring. - uint8_t falloff = static_cast(((thickness - diff) * 255) / thickness); - // Older ripples (large radius) fade out. - uint8_t age_fade = static_cast(255 - ((radius_[i] * 255u) / maxR)); - uint8_t intensity = scale8(falloff, age_fade); - RGB c = hsvToRgb(static_cast(hue_[i] + hue_shift), 240, intensity); - r_acc = static_cast(r_acc + c.r); - g_acc = static_cast(g_acc + c.g); - b_acc = static_cast(b_acc + c.b); - } - } - if (cpl >= 1) row[0] = r_acc > 255 ? 255 : static_cast(r_acc); - if (cpl >= 2) row[1] = g_acc > 255 ? 255 : static_cast(g_acc); - if (cpl >= 3) row[2] = b_acc > 255 ? 255 : static_cast(b_acc); - row += cpl; + const float dx = static_cast(x) - cx; + const float dz = static_cast(z) - cz; + // Distance from the floor-plane centre, scaled to height. + const float dist = std::sqrt(dx * dx + dz * dz) / 9.899495f * static_cast(h); + const float phase = dist / rippleInterval + timeInterval; + const lengthType y = static_cast( + std::floor(static_cast(h) / 2.0f * (1.0f + std::sin(phase)))); + if (y < 0 || y >= h) continue; + + const uint8_t hue = static_cast( + elapsed() / 50u + static_cast(x) * 3u + static_cast(z) * 7u); + const RGB c = hsvToRgb(hue, 255, 255); + + // Buffer layout is (z * h * w + y * w + x) — see NoiseEffect. + const nrOfLightsType idx = static_cast(z) * wh + + static_cast(y) * w + x; + uint8_t* px = buf + static_cast(idx) * cpl; + if (cpl >= 1) px[0] = c.r; + if (cpl >= 2) px[1] = c.g; + if (cpl >= 3) px[2] = c.b; } } } - -private: - lengthType cx_[MAX_RIPPLES] = {}; - lengthType cy_[MAX_RIPPLES] = {}; - uint8_t radius_[MAX_RIPPLES] = {}; - uint8_t hue_[MAX_RIPPLES] = {}; - bool initialized_ = false; - uint32_t lastElapsed_ = 0; - uint32_t rngState_ = 0xC0DECAFEu; - - uint8_t rand8() { - rngState_ = rngState_ * 1103515245u + 12345u; - return static_cast((rngState_ >> 16) & 0xFF); - } - - void spawn(uint8_t i, lengthType w, lengthType h) { - cx_[i] = static_cast((static_cast(rand8()) * w) >> 8); - cy_[i] = static_cast((static_cast(rand8()) * h) >> 8); - radius_[i] = 0; - hue_[i] = rand8(); - } }; } // namespace mm diff --git a/src/main.cpp b/src/main.cpp index 63e2e145..dea8517b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -13,16 +13,24 @@ #include "light/effects/GlowParticlesEffect.h" #include "light/effects/CheckerboardEffect.h" #include "light/effects/SpiralEffect.h" +#include "light/effects/RingsEffect.h" #include "light/effects/RipplesEffect.h" #include "light/effects/LavaLampEffect.h" #include "light/effects/GameOfLifeEffect.h" +#include "light/effects/NetworkReceiveEffect.h" +#include "light/effects/AudioVolumeEffect.h" +#include "light/effects/AudioSpectrumEffect.h" #include "light/modifiers/MultiplyModifier.h" #include "light/modifiers/CheckerboardModifier.h" -#include "light/drivers/ArtNetSendDriver.h" +#include "light/drivers/NetworkSendDriver.h" #include "light/drivers/PreviewDriver.h" +#include "light/drivers/LcdLedDriver.h" +#include "light/drivers/ParlioLedDriver.h" +#include "light/drivers/RmtLedDriver.h" #include "core/HttpServerModule.h" #include "core/SystemModule.h" #include "core/BoardModule.h" +#include "core/AudioModule.h" #include "core/FirmwareUpdateModule.h" #include "core/ImprovProvisioningModule.h" #include "core/FilesystemModule.h" @@ -57,16 +65,24 @@ static void registerModuleTypes() { mm::ModuleFactory::registerType("GlowParticlesEffect", "light/effects/GlowParticlesEffect.md"); mm::ModuleFactory::registerType("CheckerboardEffect", "light/effects/CheckerboardEffect.md"); mm::ModuleFactory::registerType("SpiralEffect", "light/effects/SpiralEffect.md"); + mm::ModuleFactory::registerType("RingsEffect", "light/effects/RingsEffect.md"); mm::ModuleFactory::registerType("RipplesEffect", "light/effects/RipplesEffect.md"); mm::ModuleFactory::registerType("LavaLampEffect", "light/effects/LavaLampEffect.md"); mm::ModuleFactory::registerType("GameOfLifeEffect", "light/effects/GameOfLifeEffect.md"); + mm::ModuleFactory::registerType("NetworkReceiveEffect", "light/effects/NetworkReceiveEffect.md"); + mm::ModuleFactory::registerType("AudioVolumeEffect", "light/effects/AudioVolumeEffect.md"); + mm::ModuleFactory::registerType("AudioSpectrumEffect", "light/effects/AudioSpectrumEffect.md"); mm::ModuleFactory::registerType("MultiplyModifier", "light/modifiers/MultiplyModifier.md"); mm::ModuleFactory::registerType("CheckerboardModifier", "light/modifiers/CheckerboardModifier.md"); - mm::ModuleFactory::registerType("ArtNetSendDriver", "light/drivers/ArtNetSendDriver.md"); + mm::ModuleFactory::registerType("NetworkSendDriver", "light/drivers/NetworkSendDriver.md"); mm::ModuleFactory::registerType("PreviewDriver", "light/drivers/PreviewDriver.md"); + mm::ModuleFactory::registerType("RmtLedDriver", "light/drivers/RmtLedDriver.md"); + mm::ModuleFactory::registerType("LcdLedDriver", "light/drivers/LcdLedDriver.md"); + mm::ModuleFactory::registerType("ParlioLedDriver", "light/drivers/ParlioLedDriver.md"); mm::ModuleFactory::registerType("HttpServerModule", "core/HttpServerModule.md"); mm::ModuleFactory::registerType("SystemModule", "core/SystemModule.md"); mm::ModuleFactory::registerType("BoardModule", "core/BoardModule.md"); + mm::ModuleFactory::registerType("AudioModule", "core/AudioModule.md"); mm::ModuleFactory::registerType("FirmwareUpdateModule", "core/FirmwareUpdateModule.md"); mm::ModuleFactory::registerType("ImprovProvisioningModule", "core/ImprovProvisioningModule.md"); mm::ModuleFactory::registerType("NetworkModule", "core/NetworkModule.md"); @@ -96,11 +112,11 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) { // Names come from ModuleFactory::create via displayNameFor — strips the // role suffix (Effect/Modifier/Layout/Driver, plus Module for generics) so - // e.g. NoiseEffect → "Noise", FilesystemModule → "Filesystem". For drivers, - // the Send/Receive part is kept so siblings like ArtNetSendDriver and a - // future ArtNetReceiveDriver stay distinguishable as "ArtNetSend" and - // "ArtNetReceive". setName() overrides are only needed for genuine renames, - // not for default display. + // e.g. NoiseEffect → "Noise", FilesystemModule → "Filesystem". For network + // modules the Send/Receive part is kept so NetworkSendDriver ("NetworkSend") + // and NetworkReceiveEffect ("NetworkReceive") stay distinguishable. + // setName() overrides are only needed for genuine renames, not for default + // display. // Note: ModuleFactory::create can in principle return nullptr (factory entry // missing, OOM at probe construction). We deliberately do not null-check @@ -129,6 +145,15 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) { systemModule->addChild(boardModule); boardModule->markWiredByCode(); + // AudioModule is NOT auto-wired. It is a mic peripheral, useful only on a board + // that actually has an I2S microphone, so the user adds it through the UI when + // they have one (the same model as the effects: registered in the factory, + // user-added, not boot-wired). Auto-wiring it on every flash forced an I2S init + // on boards with no mic, which on the classic ESP32 hung setup() and boot-looped + // the device. When added, its pins default to empty so it stays idle until the + // user enters the real GPIOs. The audio effects reach it via the static + // AudioModule::latestFrame(), which returns a silent frame when no mic exists. + // FirmwareUpdate — surfaces OTA status as two read-only controls. // The actual flash is driven by POST /api/firmware/url; this module just // polls the shared globals so the WS push picks up progress. @@ -207,9 +232,36 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) { auto* drivers = static_cast(mm::ModuleFactory::create("Drivers")); drivers->setLayers(layersContainer); - auto* artnet = mm::ModuleFactory::create("ArtNetSendDriver"); - drivers->addChild(artnet); // name = "ArtNetSend" (factory default) — disambiguates from a future ArtNetReceive - artnet->markWiredByCode(); + auto* netSend = mm::ModuleFactory::create("NetworkSendDriver"); + drivers->addChild(netSend); // name = "NetworkSend" (factory default) + netSend->markWiredByCode(); + + // RMT WS2812 LED output — any chip with RMT TX channels (classic ESP32: 8, + // S3: 4; the seam is a no-op on desktop). Wired by code like NetworkSend so + // a persistence load can't drop it. + if constexpr (mm::platform::rmtTxChannels > 0) { + auto* led = mm::ModuleFactory::create("RmtLedDriver"); + drivers->addChild(led); + led->markWiredByCode(); + } + + // LCD_CAM parallel WS2812 output — chips with the i80 LCD peripheral (the + // S3 among current targets): 8 strands clock out simultaneously over one + // DMA transfer, the S3's scale path beyond its 4 RMT channels. + if constexpr (mm::platform::lcdLanes > 0) { + auto* lcd = mm::ModuleFactory::create("LcdLedDriver"); + drivers->addChild(lcd); + lcd->markWiredByCode(); + } + + // Parlio parallel WS2812 output — chips with the Parlio peripheral (the + // ESP32-P4 among current targets): the P4's scale path, sibling of the LCD + // driver, 8 strands over one DMA transfer. + if constexpr (mm::platform::parlioLanes > 0) { + auto* parlio = mm::ModuleFactory::create("ParlioLedDriver"); + drivers->addChild(parlio); + parlio->markWiredByCode(); + } auto* preview = static_cast(mm::ModuleFactory::create("PreviewDriver")); // PreviewDriver reads the active Layer (resolved by the Drivers container's @@ -259,7 +311,11 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) { std::printf("sizeof: MoonModule=%zu Layer=%zu Drivers=%zu Grid=%zu HttpServer=%zu\n", sizeof(mm::MoonModule), sizeof(mm::Layer), sizeof(mm::Drivers), sizeof(mm::GridLayout), sizeof(mm::HttpServerModule)); - std::printf("ArtNet → %s\n", static_cast(artnet)->ip); + // The ip control is 4 raw octets, not a string — format before printing + // (the old %s on the byte array printed garbage). + char netSendIp[16]; + mm::formatDottedQuad(netSendIp, static_cast(netSend)->ip); + std::printf("NetworkSend → %s\n", netSendIp); // The server binds all interfaces (INADDR_ANY) — reachable from other // devices on the LAN, not only localhost. std::printf("HTTP server → http://localhost:%u\n", httpServer->port); diff --git a/src/platform/desktop/platform_config.h b/src/platform/desktop/platform_config.h index 0972df70..53c7496e 100644 --- a/src/platform/desktop/platform_config.h +++ b/src/platform/desktop/platform_config.h @@ -2,14 +2,39 @@ // Desktop platform configuration — always uses larger types (PSRAM-like) +#include + namespace mm::platform { constexpr bool hasPsram = true; +// Not an ESP32-P4, so the P4-specific seams (Ethernet pin map, co-processor WiFi) +// compile out on desktop. Mirrors the esp32 config, which keeps only isEsp32P4 +// (the general isEsp32/isEsp32S3 family flags had no users and were removed). +constexpr bool isEsp32P4 = false; + +// No RMT peripheral — the RMT LED driver guards on this and is inert on desktop. +constexpr uint8_t rmtTxChannels = 0; + +// No LCD_CAM peripheral — the LCD LED driver guards on this and is inert too. +constexpr uint8_t lcdLanes = 0; + +// No Parlio peripheral — the Parlio LED driver guards on this and is inert too. +constexpr uint8_t parlioLanes = 0; + +// No I2S microphone — AudioModule guards on this and is inert on desktop. The +// audioFft seam still has a (naive-DFT) desktop implementation so the audio +// band math runs end-to-end in host tests; only live capture is absent. +constexpr bool hasI2sMic = false; + // Desktop is not a target of the Ethernet-only firmware profile; it ships // WiFi stubs and exercises the hasWiFi==true code path for compile coverage. constexpr bool hasWiFi = true; +// Desktop has no separate WiFi co-processor (the ESP32-P4 + C6 case); the +// coprocessorWifi() read-out and its SystemModule control compile out here. +constexpr bool hasWifiCoprocessor = false; + // OTA writes to an ESP-IDF OTA partition; desktop has none. FirmwareUpdateModule // + the /api/firmware/url route `if constexpr (hasOta)` to a 501 stub instead. constexpr bool hasOta = false; diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index b2613ecc..7f364c19 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -2,6 +2,7 @@ #include #include +#include // cosf/sinf/sqrtf for the naive desktop DFT (audioFft) #include #include #include @@ -121,6 +122,10 @@ void delayMs(uint32_t ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); } +void delayUs(uint32_t us) { + std::this_thread::sleep_for(std::chrono::microseconds(us)); +} + size_t freeHeap() { return 0; // Not meaningful on desktop (0 = unlimited) } @@ -195,6 +200,10 @@ const char* sdkVersion() { #endif } +const char* coprocessorWifi() { + return ""; // desktop has no WiFi co-processor +} + const char* resetReason() { // Desktop has no reset-reason concept; report a benign value the UI treats as "not crashed". return "OK"; @@ -432,6 +441,43 @@ bool UdpSocket::sendTo(const uint8_t* data, size_t len) { return ::send(sock(fd_), reinterpret_cast(data), static_cast(len), 0) >= 0; } +bool UdpSocket::bind(uint16_t port) { + if (fd_ < 0) return false; + int reuse = 1; + ::setsockopt(sock(fd_), SOL_SOCKET, SO_REUSEADDR, + reinterpret_cast(&reuse), sizeof(reuse)); + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + addr.sin_addr.s_addr = htonl(INADDR_ANY); + if (::bind(sock(fd_), reinterpret_cast(&addr), sizeof(addr)) != 0) return false; + // Non-blocking so the render loop's drain never stalls waiting for a packet. + return make_nonblocking(fd_) == 0; +} + +int UdpSocket::recvFrom(uint8_t* buf, size_t maxLen, uint8_t srcIp[4]) { + if (fd_ < 0) return -1; + sockaddr_in src{}; + socklen_t srcLen = sizeof(src); + auto n = ::recvfrom(sock(fd_), reinterpret_cast(buf), static_cast(maxLen), 0, + reinterpret_cast(&src), &srcLen); + // 0-byte datagrams and would-block both mean "nothing usable pending". + if (n <= 0) return -1; + if (srcIp) std::memcpy(srcIp, &src.sin_addr.s_addr, 4); // network order = octets + return static_cast(n); +} + +bool UdpSocket::sendToAddr(const uint8_t ip[4], uint16_t port, + const uint8_t* data, size_t len) { + if (fd_ < 0) return false; + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + std::memcpy(&addr.sin_addr.s_addr, ip, 4); + return ::sendto(sock(fd_), reinterpret_cast(data), static_cast(len), 0, + reinterpret_cast(&addr), sizeof(addr)) >= 0; +} + void UdpSocket::close() { if (fd_ >= 0) { close_sock(fd_); @@ -606,4 +652,103 @@ void TcpServer::close() { } } +// --------------------------------------------------------------------------- +// RMT WS2812 — no-op stubs. Desktop has no RMT peripheral; the driver guards +// every call with `if constexpr (platform::rmtTxChannels == 0)` (0 here), so +// these exist only to satisfy the linker and are never reached at runtime. +// --------------------------------------------------------------------------- +bool rmtWs2812Init(RmtWs2812Handle& /*h*/, uint8_t /*gpio*/, uint32_t /*resolutionHz*/, + bool /*invert*/) { + return false; +} +uint32_t rmtWs2812Resolution(const RmtWs2812Handle& /*h*/) { return 0; } +bool rmtWs2812Transmit(RmtWs2812Handle& /*h*/, const uint32_t* /*symbols*/, + size_t /*symbolCount*/) { + return false; +} +void rmtWs2812Wait(RmtWs2812Handle& /*h*/, uint32_t /*timeoutMs*/) {} +void rmtWs2812Deinit(RmtWs2812Handle& /*h*/) {} +size_t rmtWs2812RxCapture(uint8_t /*gpio*/, uint32_t /*resolutionHz*/, + uint32_t* /*outSymbols*/, size_t /*maxSymbols*/, + uint32_t /*timeoutMs*/) { + return 0; +} +RmtLoopbackResult rmtWs2812Loopback(uint8_t /*txGpio*/, uint8_t /*rxGpio*/) { + return {}; // not supported off ESP32 +} +RmtLoopbackResult rmtWs2812LoopbackFrame(uint8_t /*txGpio*/, uint8_t /*rxGpio*/, + uint16_t /*lights*/, uint8_t /*channels*/) { + return {}; // not supported off ESP32 +} + +// --------------------------------------------------------------------------- +// LCD_CAM WS2812 — no-op stubs. Desktop has no i80 peripheral; the LCD LED +// driver guards every call with `if constexpr (platform::lcdLanes == 0)` +// (0 here), so these exist only to satisfy the linker. +// --------------------------------------------------------------------------- +bool lcdWs2812Init(LcdWs2812Handle& /*h*/, const uint16_t* /*dataPins*/, + uint8_t /*laneCount*/, uint16_t /*wrGpio*/, uint16_t /*dcGpio*/, + size_t /*bufferBytes*/) { + return false; +} +uint8_t* lcdWs2812Buffer(const LcdWs2812Handle& /*h*/) { return nullptr; } +size_t lcdWs2812BufferCapacity(const LcdWs2812Handle& /*h*/) { return 0; } +bool lcdWs2812Transmit(LcdWs2812Handle& /*h*/, size_t /*bytes*/) { return false; } +void lcdWs2812Wait(LcdWs2812Handle& /*h*/, uint32_t /*timeoutMs*/) {} +void lcdWs2812Deinit(LcdWs2812Handle& /*h*/) {} +RmtLoopbackResult lcdWs2812Loopback(const uint16_t* /*dataPins*/, uint8_t /*laneCount*/, + uint16_t /*wrGpio*/, uint16_t /*dcGpio*/, + uint16_t /*rxGpio*/, const uint8_t* /*frame*/, + size_t /*frameBytes*/, size_t /*dataBytes*/, + uint8_t /*rowBits*/) { + return {}; // not supported off the S3 +} + +// Parlio WS2812 — no-op stubs. Desktop has no Parlio peripheral; the driver +// idles (parlioLanes == 0). Sizing/slicing is host-pinned by the driver tests. +bool parlioWs2812Init(ParlioWs2812Handle& /*h*/, const uint16_t* /*dataPins*/, + uint8_t /*laneCount*/, uint32_t /*pclkHz*/, size_t /*bufferBytes*/) { + return false; +} +uint8_t* parlioWs2812Buffer(const ParlioWs2812Handle& /*h*/) { return nullptr; } +size_t parlioWs2812BufferCapacity(const ParlioWs2812Handle& /*h*/) { return 0; } +bool parlioWs2812Transmit(ParlioWs2812Handle& /*h*/, size_t /*bytes*/) { return false; } +void parlioWs2812Wait(ParlioWs2812Handle& /*h*/, uint32_t /*timeoutMs*/) {} +void parlioWs2812Deinit(ParlioWs2812Handle& /*h*/) {} +RmtLoopbackResult parlioWs2812Loopback(const uint16_t* /*dataPins*/, uint8_t /*laneCount*/, + uint16_t /*rxGpio*/, const uint8_t* /*frame*/, + size_t /*frameBytes*/, size_t /*dataBytes*/, + uint8_t /*rowBits*/) { + return {}; // not supported off the P4 +} + +// I2S microphone — no capture on desktop (hasI2sMic == false, AudioModule inert), +// so init fails and read returns nothing. +bool audioMicInit(AudioMicHandle& /*h*/, uint16_t /*wsPin*/, uint16_t /*sdPin*/, + uint16_t /*sckPin*/, uint32_t /*sampleRate*/) { + return false; +} +size_t audioMicRead(AudioMicHandle& /*h*/, int32_t* /*out*/, size_t /*maxSamples*/) { + return 0; +} +void audioMicDeinit(AudioMicHandle& /*h*/) {} + +// FFT kernel — a real but naive O(n^2) DFT. NOT the production kernel (the ESP32 +// uses esp-dsp's fast radix-2), but a correct reference so the host tests run the +// genuine magnitude->band path on synthesized signals. n must be a power of two; +// fills outMag[0..n/2) with the bin magnitudes. +void audioFft(const float* windowed, size_t n, float* outMag) { + if (!windowed || !outMag || n == 0) return; + const float twoPiOverN = -2.0f * 3.14159265358979323846f / static_cast(n); + for (size_t k = 0; k < n / 2; k++) { + float re = 0.0f, im = 0.0f; + for (size_t t = 0; t < n; t++) { + const float a = twoPiOverN * static_cast(k) * static_cast(t); + re += windowed[t] * std::cos(a); + im += windowed[t] * std::sin(a); + } + outMag[k] = std::sqrt(re * re + im * im); + } +} + } // namespace mm::platform diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h index 6f6526fb..03e9bf28 100644 --- a/src/platform/esp32/platform_config.h +++ b/src/platform/esp32/platform_config.h @@ -4,6 +4,8 @@ #include "sdkconfig.h" +#include + namespace mm::platform { #ifdef CONFIG_SPIRAM @@ -12,6 +14,72 @@ constexpr bool hasPsram = true; constexpr bool hasPsram = false; #endif +// Which ESP32 silicon family this build targets. Capability gating +// (rmtTxChannels, lcdLanes, parlioLanes, hasI2sMic) keys off SOC flags, not the +// family, so a new chip works untouched and no general isEsp32/isEsp32S3 flag is +// needed (the ones that existed had no users and were removed). isEsp32P4 remains +// because the P4 has two genuinely chip-specific seams that aren't SOC-derived: +// its Ethernet pin map (ethPins) and its co-processor WiFi (hasWifiCoprocessor). +// Keyed off the IDF target macro; false on desktop. +#ifdef CONFIG_IDF_TARGET_ESP32P4 +constexpr bool isEsp32P4 = true; +#else +constexpr bool isEsp32P4 = false; +#endif + +// RMT TX channels this chip offers (8 on classic ESP32, 4 on the S3 and P4, +// straight from the IDF SOC capability config). Doubles as the RMT capability +// flag: the RMT LED driver and its main.cpp registration guard on +// `rmtTxChannels > 0` instead of a chip-family flag, so a new RMT-bearing +// target works untouched. +#ifdef CONFIG_SOC_RMT_SUPPORTED +constexpr uint8_t rmtTxChannels = CONFIG_SOC_RMT_TX_CANDIDATES_PER_GROUP; +#else +constexpr uint8_t rmtTxChannels = 0; +#endif + +// Parallel WS2812 lanes over the LCD_CAM i80 bus (ESP32-S3 among current +// targets). The peripheral does 16; this increment deliberately caps at 8 — +// half the DMA footprint, and widening is a constant change, not a redesign. +// SOC-derived like rmtTxChannels so a future LCD_CAM-bearing chip works +// untouched. +// +// Gate on SOC_LCDCAM_I80_LCD_SUPPORTED (the LCD_CAM peripheral's i80 mode), +// NOT SOC_LCD_I80_SUPPORTED: the classic ESP32 sets the latter for its +// unrelated I2S-LCD peripheral, so gating on it wired this driver onto the +// classic chip and hung its boot trying to init an esp_lcd i80 bus the chip +// doesn't have. SOC_LCDCAM_I80_LCD_SUPPORTED is defined only on chips with the +// real LCD_CAM (S3/P4), which is what esp_lcd's i80 driver actually needs. +#ifdef CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED +constexpr uint8_t lcdLanes = 8; +#else +constexpr uint8_t lcdLanes = 0; +#endif + +// Parallel WS2812 lanes over the Parlio (Parallel IO) TX peripheral — the +// ESP32-P4's scale path. The unit does 16 data lines; capped at 8 here to +// mirror lcdLanes (half the DMA footprint; widening is a constant change). +// SOC-derived like the others, so a future Parlio-bearing chip works untouched. +// Unlike i80, Parlio takes the data GPIOs directly (no sacrificial WR/DC) and +// allows any lane count, so ParlioLedDriver has no exactly-8-pins rule. +#ifdef CONFIG_SOC_PARLIO_SUPPORTED +constexpr uint8_t parlioLanes = 8; +#else +constexpr uint8_t parlioLanes = 0; +#endif + +// I2S audio input (an INMP441-class digital MEMS microphone). SOC-derived like +// the LED-peripheral flags: every current ESP32 has I2S, so this is true on all +// of them, but the gate keeps AudioModule + the I2S platform seam inert on any +// future I2S-less target and on desktop. The audio math (RMS, FFT bands) is +// host-tested domain code; only the I2S read and the FFT kernel sit behind the +// boundary, both guarded by `if constexpr (platform::hasI2sMic)`. +#ifdef CONFIG_SOC_I2S_SUPPORTED +constexpr bool hasI2sMic = true; +#else +constexpr bool hasI2sMic = false; +#endif + // WiFi is compiled out in the Ethernet-only build profile. ESP-IDF v6.x has no // CONFIG_ESP_WIFI_ENABLED switch, so the eth-only build instead drops the WiFi // components via EXCLUDE_COMPONENTS and defines MM_NO_WIFI (see esp32/main/CMakeLists.txt). @@ -21,6 +89,16 @@ constexpr bool hasWiFi = false; constexpr bool hasWiFi = true; #endif +// The P4 has no native radio; when it has WiFi at all (the esp32p4-eth-wifi build), +// that WiFi runs on the on-board ESP32-C6 over SDIO via esp_wifi_remote / esp_hosted. +// The esp_wifi_* API is identical to native and esp_hosted self-initialises at boot, +// so the WiFi *path* needs no branch. This flag exists only so the co-processor +// firmware read-out (SystemModule's `wifiCoproc` control + platform::coprocessorWifi) +// compiles in ONLY on a build that actually has a co-processor — on every other +// target the buffer, the calls, and the control vanish (if constexpr), keeping the +// flash/RAM cost off boards that can't use it. +constexpr bool hasWifiCoprocessor = isEsp32P4 && hasWiFi; + // Ethernet is only available on firmware variants whose sdkconfig fragment // enables the ESP32 EMAC (sdkconfig.defaults.eth — Olimex pin map). Other // firmwares (plain ESP32 WiFi-only, ESP32-S3 with no EMAC) define MM_NO_ETH @@ -31,6 +109,34 @@ constexpr bool hasEthernet = false; constexpr bool hasEthernet = true; #endif +// Per-board Ethernet RMII / PHY pin map. The pins are NOT runtime-configurable +// today (full runtime PHY/pin selection is a 2.0 backlog item); they are a +// compile-time-per-target constant so the platform boundary stays clean — no +// scattered #ifdefs in ethInit(), which reads this struct instead of literals. +// Plain ints (not IDF enums) keep this header free of esp_eth includes; ethInit +// translates rmiiClockExtIn → EMAC_CLK_EXT_IN/OUT and isIp101 → the PHY ctor. +struct EthPinConfig { + int phyAddr; + int mdcGpio; // SMI clock; -1 = leave at IDF default + int mdioGpio; // SMI data; -1 = leave at IDF default + int rstGpio; // PHY reset + int rmiiClockGpio; // RMII 50 MHz reference clock pin + bool rmiiClockExtIn; // true = clock IN (board feeds it), false = chip drives it OUT + bool isIp101; // true = IP101 PHY ctor, false = generic +}; + +// ESP32-P4-NANO (Waveshare): IP101 PHY, addr 1, MDC/MDIO 31/52, reset 51, +// external 50 MHz RMII clock fed IN on GPIO50. Source: Waveshare wiki + +// schematic + the ESPHome device page (two independent sources agree). +// Else: Olimex ESP32-Gateway Rev G — LAN8720 (generic PHY), addr 0, reset 5, +// chip drives the RMII clock OUT on GPIO17, MDC/MDIO left at IDF defaults (the +// pins this board has always used; now a named config instead of literals). +constexpr EthPinConfig ethPins = + isEsp32P4 ? EthPinConfig{ /*phyAddr*/ 1, /*mdc*/ 31, /*mdio*/ 52, /*rst*/ 51, + /*rmiiClk*/ 50, /*extIn*/ true, /*ip101*/ true } + : EthPinConfig{ /*phyAddr*/ 0, /*mdc*/ -1, /*mdio*/ -1, /*rst*/ 5, + /*rmiiClk*/ 17, /*extIn*/ false, /*ip101*/ false }; + // OTA (esp_https_ota) is available on every ESP32 build — the OTA partition // layout in partitions/*.csv reserves app0/app1 unconditionally, and esp_https_ota // is in baseline ESP-IDF. FirmwareUpdateModule + the /api/firmware/url route diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp index dbe5c420..39030ecd 100644 --- a/src/platform/esp32/platform_esp32.cpp +++ b/src/platform/esp32/platform_esp32.cpp @@ -32,10 +32,22 @@ #include "esp_event.h" #include "esp_netif.h" #include "esp_eth.h" +#ifdef CONFIG_IDF_TARGET_ESP32P4 +#include "esp_eth_phy_ip101.h" // P4-NANO PHY — managed component espressif/ip101 +#endif #ifndef MM_NO_WIFI #include "esp_wifi.h" +#if defined(CONFIG_IDF_TARGET_ESP32P4) +// On the P4 (WiFi build only — this is inside #ifndef MM_NO_WIFI), esp_wifi_* is +// forwarded to the on-board ESP32-C6 by esp_wifi_remote / esp_hosted. esp_hosted +// self-initialises at boot via a constructor, so no bring-up call is needed (see +// ensureWifiInit); this header is only for the read-only coprocessorWifi() query +// that reports the C6's slave-firmware version. Matches the guard on that function. +#include "esp_hosted.h" +#endif #endif #include "esp_log.h" +#include "esp_rom_sys.h" // esp_rom_delay_us (delayUs) #include "mdns.h" #include "driver/gpio.h" #include "freertos/FreeRTOS.h" @@ -91,6 +103,12 @@ void delayMs(uint32_t ms) { vTaskDelay(pdMS_TO_TICKS(ms)); } +void delayUs(uint32_t us) { + // Busy-wait — fine for the few-hundred-µs protocol gaps this exists for + // (e.g. the WS2812 inter-frame latch), off any latency-critical context. + esp_rom_delay_us(us); +} + void reboot() { esp_restart(); } @@ -144,6 +162,7 @@ const char* chipModel() { case CHIP_ESP32S2: return "ESP32-S2"; case CHIP_ESP32S3: return "ESP32-S3"; case CHIP_ESP32C3: return "ESP32-C3"; + case CHIP_ESP32P4: return "ESP32-P4"; default: return "ESP32-?"; } } @@ -158,6 +177,30 @@ const char* sdkVersion() { return esp_get_idf_version(); } +const char* coprocessorWifi() { +#if defined(CONFIG_IDF_TARGET_ESP32P4) && !defined(MM_NO_WIFI) + // The P4's WiFi runs on the on-board ESP32-C6 via esp_hosted. Ask the host API + // what slave firmware version the C6 actually reported over the link. A version + // of 0.0.0 (or an error) means the slave never completed its handshake — the + // signature of absent / incompatible C6 slave firmware, which is exactly the + // case we want to surface rather than infer. + static char buf[24] = "querying…"; + esp_hosted_coprocessor_fwver_t ver = {}; + if (esp_hosted_get_coprocessor_fwversion(&ver) == ESP_OK + && (ver.major1 || ver.minor1 || ver.patch1)) { + std::snprintf(buf, sizeof(buf), "C6 fw %u.%u.%u", + static_cast(ver.major1), + static_cast(ver.minor1), + static_cast(ver.patch1)); + } else { + std::snprintf(buf, sizeof(buf), "not detected"); + } + return buf; +#else + return ""; // native-radio targets have no WiFi co-processor +#endif +} + const char* resetReason() { switch (esp_reset_reason()) { case ESP_RST_POWERON: return "POWERON"; @@ -258,27 +301,60 @@ bool ethInit() { esp_netif_config_t netif_cfg = ESP_NETIF_DEFAULT_ETH(); ethNetif_ = esp_netif_new(&netif_cfg); - // MAC config — Olimex ESP32-Gateway Rev G: RMII clock output on GPIO17 + // RMII / PHY pins come from the per-target ethPins config (platform_config.h) + // — the Olimex map by default, the P4-NANO's IP101 map on the P4. ethPins is + // a compile-time constant, so the unused branch (and its PHY ctor) is dropped. eth_mac_config_t mac_config = ETH_MAC_DEFAULT_CONFIG(); eth_esp32_emac_config_t emac_config = ETH_ESP32_EMAC_DEFAULT_CONFIG(); - emac_config.clock_config.rmii.clock_mode = EMAC_CLK_OUT; - emac_config.clock_config.rmii.clock_gpio = static_cast(GPIO_NUM_17); + emac_config.clock_config.rmii.clock_mode = + ethPins.rmiiClockExtIn ? EMAC_CLK_EXT_IN : EMAC_CLK_OUT; + emac_config.clock_config.rmii.clock_gpio = + static_cast(ethPins.rmiiClockGpio); + if (ethPins.mdcGpio >= 0) emac_config.smi_gpio.mdc_num = ethPins.mdcGpio; + if (ethPins.mdioGpio >= 0) emac_config.smi_gpio.mdio_num = ethPins.mdioGpio; - // PHY config — Olimex ESP32-Gateway: LAN8720, addr 0, reset GPIO 5 eth_phy_config_t phy_config = ETH_PHY_DEFAULT_CONFIG(); - phy_config.phy_addr = 0; - phy_config.reset_gpio_num = 5; + phy_config.phy_addr = ethPins.phyAddr; + phy_config.reset_gpio_num = ethPins.rstGpio; + + // Helper to unwind whatever was created so far on any failure — ethInit + // runs once at boot, but a clean teardown means a broken PHY/cable degrades + // (returns false → the WiFi/AP cascade takes over) instead of leaking the + // netif + MAC/PHY drivers. + auto fail = [&](const char* what, esp_eth_mac_t* m, esp_eth_phy_t* p) -> bool { + ESP_LOGE(NET_TAG, "Ethernet %s", what); + if (p) p->del(p); + if (m) m->del(m); + if (ethNetif_) { esp_netif_destroy(ethNetif_); ethNetif_ = nullptr; } + return false; + }; esp_eth_mac_t* mac = esp_eth_mac_new_esp32(&emac_config, &mac_config); - esp_eth_phy_t* phy = esp_eth_phy_new_generic(&phy_config); + if (!mac) return fail("MAC create failed", nullptr, nullptr); + // IP101 (P4-NANO) is a managed-component PHY ctor (espressif/ip101 in + // idf_component.yml; removed from esp_eth core in IDF v6); the generic ctor + // (Olimex LAN8720) stays in core. The IP101 symbol is only declared on the + // P4 build (its header include is #ifdef'd), so the *call* must be guarded + // at the preprocessor level too — `if constexpr` discards a dead branch but + // still requires it to compile, and an undeclared symbol won't. ethPins is + // constexpr, so on P4 the runtime branch still folds away. + esp_eth_phy_t* phy; +#ifdef CONFIG_IDF_TARGET_ESP32P4 + if (ethPins.isIp101) phy = esp_eth_phy_new_ip101(&phy_config); + else phy = esp_eth_phy_new_generic(&phy_config); +#else + phy = esp_eth_phy_new_generic(&phy_config); +#endif + if (!phy) return fail("PHY create failed", mac, nullptr); esp_eth_config_t eth_config = ETH_DEFAULT_CONFIG(mac, phy); esp_eth_handle_t eth_handle = nullptr; esp_err_t err = esp_eth_driver_install(ð_config, ð_handle); if (err != ESP_OK) { - ESP_LOGE(NET_TAG, "Ethernet driver install failed: %s", esp_err_to_name(err)); - return false; + return fail(esp_err_to_name(err), mac, phy); } + // From here the driver owns mac+phy (driver_uninstall frees them); the + // remaining failure paths uninstall the driver instead of del-ing mac/phy. ESP_ERROR_CHECK(esp_netif_attach(ethNetif_, esp_eth_new_netif_glue(eth_handle))); ESP_ERROR_CHECK(esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, @@ -289,6 +365,10 @@ bool ethInit() { err = esp_eth_start(eth_handle); if (err != ESP_OK) { ESP_LOGE(NET_TAG, "Ethernet start failed: %s", esp_err_to_name(err)); + esp_event_handler_unregister(ETH_EVENT, ESP_EVENT_ANY_ID, ðEventHandler); + esp_event_handler_unregister(IP_EVENT, IP_EVENT_ETH_GOT_IP, ðEventHandler); + esp_eth_driver_uninstall(eth_handle); // frees mac + phy + if (ethNetif_) { esp_netif_destroy(ethNetif_); ethNetif_ = nullptr; } return false; } @@ -355,6 +435,19 @@ static void wifiEventHandler(void* /*arg*/, esp_event_base_t base, // init failure is a recoverable runtime error, not a panic. static bool ensureWifiInit() { if (wifiInitDone_) return true; + + // P4 note: the P4 has no native radio — WiFi runs on the on-board ESP32-C6 via + // esp_wifi_remote / esp_hosted (the esp32p4-eth-wifi build). No bring-up code is + // needed here: esp_hosted self-initialises at boot via a constructor + // (ESP_SYSTEM_INIT_FN → esp_hosted_init, the `host_init: ESP Hosted` boot line), + // which sets up the SDIO transport, RPC, and the wifi-remote channels and + // connects to the C6. After that the esp_wifi_* calls below are forwarded to the + // C6 unchanged. Do NOT call esp_hosted_init()/esp_hosted_connect_to_slave() here: + // init is already done (idempotent no-op), and connect_to_slave() is actually a + // transport *reconfigure* that resets the slave (GPIO 54) and re-inits SDIO — + // which on a live link fails (`sdmmc_card_init failed`) and tears down the + // working boot-time connection. Proven on the P4-NANO bench (2026-06-12). + ensureNetifInit(); wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); @@ -424,6 +517,17 @@ bool wifiStaInit(const char* ssid, const char* password) { return false; } + // Disable WiFi modem power-save. IDF defaults to WIFI_PS_MIN_MODEM, which + // DTIM-sleeps the radio between beacons — that sleep causes intermittent + // multi-hundred-ms stalls in TCP socket handling (the HTTP server wedges + // while UDP/DDP keeps flowing) and the LED-pause class of glitch. The whole + // lineage (WLED, v1/v2) turns it off for the same reason; a wall-powered LED + // controller has no battery to save. Non-fatal if it fails (older IDF / odd + // chip) — log and carry on. + if ((err = esp_wifi_set_ps(WIFI_PS_NONE)) != ESP_OK) { + ESP_LOGW(NET_TAG, "WiFi power-save disable failed: %s", esp_err_to_name(err)); + } + err = esp_wifi_connect(); if (err != ESP_OK) { ESP_LOGE(NET_TAG, "WiFi STA connect failed: %s", esp_err_to_name(err)); @@ -640,6 +744,44 @@ bool UdpSocket::sendTo(const uint8_t* data, size_t len) { return ::send(fd_, data, len, 0) >= 0; } +bool UdpSocket::bind(uint16_t port) { + if (fd_ < 0) return false; + int reuse = 1; + setsockopt(fd_, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + addr.sin_addr.s_addr = htonl(INADDR_ANY); + if (::bind(fd_, reinterpret_cast(&addr), sizeof(addr)) != 0) return false; + // Non-blocking so the render loop's drain never stalls waiting for a packet. + int flags = fcntl(fd_, F_GETFL, 0); + fcntl(fd_, F_SETFL, flags | O_NONBLOCK); + return true; +} + +int UdpSocket::recvFrom(uint8_t* buf, size_t maxLen, uint8_t srcIp[4]) { + if (fd_ < 0) return -1; + sockaddr_in src{}; + socklen_t srcLen = sizeof(src); + auto n = ::recvfrom(fd_, buf, maxLen, 0, + reinterpret_cast(&src), &srcLen); + // 0-byte datagrams and EWOULDBLOCK both mean "nothing usable pending". + if (n <= 0) return -1; + if (srcIp) std::memcpy(srcIp, &src.sin_addr.s_addr, 4); // network order = octets + return static_cast(n); +} + +bool UdpSocket::sendToAddr(const uint8_t ip[4], uint16_t port, + const uint8_t* data, size_t len) { + if (fd_ < 0) return false; + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + std::memcpy(&addr.sin_addr.s_addr, ip, 4); + return ::sendto(fd_, data, len, 0, + reinterpret_cast(&addr), sizeof(addr)) >= 0; +} + void UdpSocket::close() { if (fd_ >= 0) { lwip_close(fd_); diff --git a/src/platform/esp32/platform_esp32_i2s.cpp b/src/platform/esp32/platform_esp32_i2s.cpp new file mode 100644 index 00000000..29fafd67 --- /dev/null +++ b/src/platform/esp32/platform_esp32_i2s.cpp @@ -0,0 +1,169 @@ +// I2S microphone input + FFT kernel — the peripheral half of the audio feature +// (AudioModule, src/core/AudioModule.h). The module does all the domain work: the +// level math (AudioLevel.h), windowing and the magnitude->band mapping +// (AudioBands.h). This file owns only the two seams: reading samples off an I2S +// RX channel, and the FFT itself (esp-dsp's float radix-2). +// +// The microphone is a digital I2S MEMS part (e.g. INMP441): standard/Philips +// framing, 24-bit data left-justified in a 32-bit slot, mono — the channel reads +// the one slot the part's L/R pin selects. It is self-clocked from the bit clock +// (no master clock pin). We configure a 32-bit slot and hand the raw int32 +// samples to the domain code, which shifts the 24-bit value down itself. +// +// Compiles on every ESP32 chip: everything is under SOC_I2S_SUPPORTED with inert +// stubs otherwise; the module never calls in (platform::hasI2sMic == false). + +#include "platform/platform.h" + +#include "sdkconfig.h" +#include "soc/soc_caps.h" + +#if SOC_I2S_SUPPORTED + +#include "driver/i2s_std.h" +#include "esp_log.h" +#include "dsps_fft2r.h" + +#include +#include +#include // std::nothrow + +namespace mm::platform { + +namespace { + +const char* I2S_TAG = "mm_i2s"; + +struct MicState { + i2s_chan_handle_t rx = nullptr; +}; + +// esp-dsp's float FFT works in place on an interleaved complex array (re, im, +// re, im, …). We keep one scratch sized to the largest block we'll see; the +// twiddle tables are initialised once, lazily, on first use. +constexpr size_t kMaxFftN = 1024; +float g_fftBuf[kMaxFftN * 2]; +bool g_fftReady = false; + +bool ensureFftInit() { + if (g_fftReady) return true; + // dsps_fft2r_init_fc32(NULL, …) uses the library's built-in max-size twiddle + // table — no caller allocation, initialised once for the process. + if (dsps_fft2r_init_fc32(nullptr, CONFIG_DSP_MAX_FFT_SIZE) != ESP_OK) { + ESP_LOGE(I2S_TAG, "esp-dsp FFT init failed"); + return false; + } + g_fftReady = true; + return true; +} + +} // namespace + +bool audioMicInit(AudioMicHandle& h, uint16_t wsPin, uint16_t sdPin, + uint16_t sckPin, uint32_t sampleRate) { + auto* st = new (std::nothrow) MicState(); + if (!st) return false; + + i2s_chan_config_t chanCfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_AUTO, I2S_ROLE_MASTER); + if (i2s_new_channel(&chanCfg, nullptr, &st->rx) != ESP_OK) { + delete st; + return false; + } + + // Standard (Philips) mode, 32-bit slot / 24-bit data, mono. The INMP441 puts + // its data in ONE slot, chosen by its L/R pin (GND=left, VDD/floating=right), + // and leaves the other empty. The bench mic is wired L/R-to-GND, so we read the + // LEFT slot — the populated one for that wiring. If a mic reads silence with + // sound present it's filling the other slot; flip this to I2S_STD_SLOT_RIGHT. + i2s_std_slot_config_t slotCfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG( + I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_MONO); + slotCfg.slot_mask = I2S_STD_SLOT_LEFT; + i2s_std_config_t stdCfg = { + .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(sampleRate), + .slot_cfg = slotCfg, + .gpio_cfg = { + .mclk = I2S_GPIO_UNUSED, // self-clocked, no master clock + .bclk = static_cast(sckPin), + .ws = static_cast(wsPin), + .dout = I2S_GPIO_UNUSED, // input only + .din = static_cast(sdPin), + .invert_flags = { .mclk_inv = false, .bclk_inv = false, .ws_inv = false }, + }, + }; + if (i2s_channel_init_std_mode(st->rx, &stdCfg) != ESP_OK + || i2s_channel_enable(st->rx) != ESP_OK) { + if (st->rx) i2s_del_channel(st->rx); + delete st; + return false; + } + + h.impl = st; + return true; +} + +size_t audioMicRead(AudioMicHandle& h, int32_t* out, size_t maxSamples) { + auto* st = static_cast(h.impl); + if (!st || !st->rx || !out || maxSamples == 0) return 0; + size_t bytesRead = 0; + // Non-blocking (0 ms): this runs in the render tick, so it must NOT wait for + // the DMA to fill (the hot-path no-blocking rule). It drains whatever the DMA + // already holds and returns immediately. A full 512-sample block takes ~23 ms + // at 22 kHz — longer than one render tick — so a single read returns only a + // partial block; AudioModule accumulates partials across ticks and runs the FFT + // when it has a whole block. On timeout the call still copies whatever was + // ready into `out` and reports it in `bytesRead`, so we use that count + // regardless of the return code (a timeout with bytesRead>0 is a partial read, + // not a failure). + i2s_channel_read(st->rx, out, maxSamples * sizeof(int32_t), &bytesRead, + 0 /* ms — non-blocking */); + return bytesRead / sizeof(int32_t); +} + +void audioMicDeinit(AudioMicHandle& h) { + auto* st = static_cast(h.impl); + if (!st) return; + if (st->rx) { + i2s_channel_disable(st->rx); + i2s_del_channel(st->rx); + } + delete st; + h.impl = nullptr; +} + +void audioFft(const float* windowed, size_t n, float* outMag) { + if (!windowed || !outMag || n == 0 || n > kMaxFftN) return; + if (!ensureFftInit()) { + for (size_t k = 0; k < n / 2; k++) outMag[k] = 0.0f; + return; + } + // Pack the real input into the interleaved complex scratch (imag = 0). + for (size_t i = 0; i < n; i++) { + g_fftBuf[2 * i] = windowed[i]; + g_fftBuf[2 * i + 1] = 0.0f; + } + dsps_fft2r_fc32(g_fftBuf, static_cast(n)); + dsps_bit_rev_fc32(g_fftBuf, static_cast(n)); + // Magnitudes of the first n/2 bins: sqrt(re^2 + im^2). + for (size_t k = 0; k < n / 2; k++) { + const float re = g_fftBuf[2 * k]; + const float im = g_fftBuf[2 * k + 1]; + outMag[k] = std::sqrt(re * re + im * im); + } +} + +} // namespace mm::platform + +#else // !SOC_I2S_SUPPORTED — inert stubs so any I2S-less target links + +namespace mm::platform { + +bool audioMicInit(AudioMicHandle&, uint16_t, uint16_t, uint16_t, uint32_t) { + return false; +} +size_t audioMicRead(AudioMicHandle&, int32_t*, size_t) { return 0; } +void audioMicDeinit(AudioMicHandle&) {} +void audioFft(const float*, size_t, float*) {} + +} // namespace mm::platform + +#endif // SOC_I2S_SUPPORTED diff --git a/src/platform/esp32/platform_esp32_improv.cpp b/src/platform/esp32/platform_esp32_improv.cpp index c57c9fce..138159f7 100644 --- a/src/platform/esp32/platform_esp32_improv.cpp +++ b/src/platform/esp32/platform_esp32_improv.cpp @@ -74,6 +74,11 @@ struct ImprovTaskState { char* boardOut = nullptr; size_t boardOutLen = 0; std::atomic* boardReady = nullptr; + + // Vendor SET_TX_POWER RPC (command 0xFD): pre-association TX-power cap in + // whole dBm for brown-out-prone boards. Same producer/consumer dance. + uint8_t* txPowerOut = nullptr; + std::atomic* txPowerReady = nullptr; }; static ImprovTaskState g_improv; // single global — only one Improv task per device @@ -172,6 +177,15 @@ static void improvSendWifiNetworks() { // Synchronous-ish scan. Replies one network per RPC frame per the Improv // spec, then a final empty payload to mark end-of-list. Limit to 10 // entries to keep the response set bounded. + // + // P4 remote-WiFi note: esp_wifi_scan_start needs the WiFi driver started. On + // native ESP32/S3 the driver is up by the time a user provisions. On the P4 the + // radio lives on the C6 and only comes up after the esp_hosted prelude in + // ensureWifiInit() (triggered by wifiApInit / wifiStaInit). If a scan is ever + // requested on a P4 that has not yet initialised WiFi, this returns an error + // cleanly (no crash) rather than scanning a cold link — acceptable for now; + // bench-verify whether a P4 provisioned from cold needs the link brought up + // here first, and if so route through the public wifiAp/wifiSta path. wifi_scan_config_t scan_cfg = {}; if (esp_wifi_scan_start(&scan_cfg, true /*block*/) != ESP_OK) { improvSendError(improv::ERROR_UNKNOWN); @@ -318,6 +332,43 @@ static void improvHandleSetBoard(const uint8_t* payload, uint8_t len) { improvSend(ImprovFrameType::RpcResponse, rpc); } +// SET_TX_POWER vendor RPC (command 0xFD) — the pre-association escape hatch +// for boards whose LDO browns out at full TX power (LOLIN S3/S2). Their +// boards.json cap (Network.txPowerSetting) normally arrives over HTTP after +// the device is online — which a browning-out board can never reach: it fails +// WiFi auth at 20 dBm before any HTTP exists (proven on the bench, +// 2026-06-10). This RPC carries the cap over the same serial channel as the +// credentials, so it persists BEFORE the first association attempt. +// +// Frame payload layout (after the standard Improv frame header): +// [0xFD] command +// [data_len] number of bytes that follow (= 1) +// [dBm] 0..21 whole dBm; 0 = no cap (lift) +// +// On valid: write into g_improv.txPowerOut, set txPowerReady. The module's +// loop1s() forwards to NetworkModule::setTxPowerSetting (persist + apply). +static constexpr uint8_t IMPROV_CMD_SET_TX_POWER = 0xFD; +static constexpr uint8_t IMPROV_ERROR_INVALID_TX_POWER = 0x81; + +static void improvHandleSetTxPower(const uint8_t* payload, uint8_t len) { + if (!g_improv.txPowerOut || !g_improv.txPowerReady) { + improvSendError(improv::ERROR_UNKNOWN_RPC); + return; + } + // payload[0] = command (dispatched on), payload[1] = data_len, payload[2] = dBm. + if (len != 3 || payload[1] != 1 || payload[2] > 21) { + improvSendError(static_cast(IMPROV_ERROR_INVALID_TX_POWER)); + return; + } + *g_improv.txPowerOut = payload[2]; + // release-store pairs with the module's acquire-load in loop1s(). + g_improv.txPowerReady->store(true, std::memory_order_release); + auto rpc = improv::build_rpc_response( + static_cast(IMPROV_CMD_SET_TX_POWER), + std::vector{}, false); + improvSend(ImprovFrameType::RpcResponse, rpc); +} + // Dispatch a completed frame from the parser. Only RPC frames carry commands // we care about; the spec lets the other types through silently. static void improvDispatchFrame(const ImprovFrameParser& parser) { @@ -331,6 +382,10 @@ static void improvDispatchFrame(const ImprovFrameParser& parser) { improvHandleSetBoard(raw, rawLen); return; } + if (rawLen >= 1 && raw[0] == IMPROV_CMD_SET_TX_POWER) { + improvHandleSetTxPower(raw, rawLen); + return; + } improv::ImprovCommand cmd = improv::parse_improv_data( parser.lastPayload(), parser.lastPayloadLen(), false); switch (cmd.command) { @@ -524,7 +579,9 @@ bool improvProvisioningInit(const ImprovDeviceInfo& info, std::atomic* ready, char* statusBuf, size_t statusBufLen, char* boardOut, size_t boardOutLen, - std::atomic* boardReady) { + std::atomic* boardReady, + uint8_t* txPowerOut, + std::atomic* txPowerReady) { if (!info.name || !info.chipFamily || !info.firmwareVersion || !ssidOut || ssidOutLen == 0 || !passwordOut || passwordOutLen == 0 || @@ -545,6 +602,9 @@ bool improvProvisioningInit(const ImprovDeviceInfo& info, g_improv.boardOut = boardOut; g_improv.boardOutLen = boardOutLen; g_improv.boardReady = boardReady; + // SET_TX_POWER opt-in, same shape. + g_improv.txPowerOut = txPowerOut; + g_improv.txPowerReady = txPowerReady; // 6 KB stack: parser is small, scan response uses std::vector + std::string // (some short-string-optimised, some heap). Priority 4 — below OTA (5), @@ -576,7 +636,9 @@ bool improvProvisioningInit(const ImprovDeviceInfo& /*info*/, std::atomic* /*ready*/, char* statusBuf, size_t statusBufLen, char* /*boardOut*/, size_t /*boardOutLen*/, - std::atomic* /*boardReady*/) { + std::atomic* /*boardReady*/, + uint8_t* /*txPowerOut*/, + std::atomic* /*txPowerReady*/) { if (statusBuf && statusBufLen > 0) { std::snprintf(statusBuf, statusBufLen, "not supported (no WiFi)"); } diff --git a/src/platform/esp32/platform_esp32_lcd.cpp b/src/platform/esp32/platform_esp32_lcd.cpp new file mode 100644 index 00000000..86587463 --- /dev/null +++ b/src/platform/esp32/platform_esp32_lcd.cpp @@ -0,0 +1,288 @@ +// LCD_CAM parallel WS2812 output — the peripheral half of the LCD LED driver +// (ESP32-S3). The driver (src/light/drivers/LcdLedDriver.h) does all the +// domain work: applies Correction and 3-slot-encodes every light into the DMA +// frame buffer (LcdSlots.h). This file owns only the peripheral — the esp_lcd +// i80 bus, the IO device, the DMA-capable frame buffer, transmit + wait, and +// the loopback test's TX side. No domain logic here. +// +// Design: the whole frame is pre-encoded into ONE buffer and sent as ONE +// gapless GDMA stream (tx_color with lcd_cmd = -1 → pure data phase). Once +// started, no CPU work remains until the done callback — there is no refill +// deadline for WiFi to miss, which is the deliberate difference from the +// ISR-refilled rings in the hpwit/FastLED LCD drivers this design studied. +// +// The file compiles on every ESP32 chip: everything is under +// SOC_LCDCAM_I80_LCD_SUPPORTED with inert stubs otherwise (classic ESP32 builds +// it too; the driver never calls in thanks to platform::lcdLanes == 0). Gate on +// SOC_LCDCAM_I80_LCD_SUPPORTED, NOT SOC_LCD_I80_SUPPORTED: the classic ESP32 sets +// the latter for its unrelated I2S-LCD peripheral, which wired this driver onto a +// chip with no LCD_CAM and hung its boot (see platform_config.h + decisions.md). + +#include "platform/platform.h" + +#include "sdkconfig.h" +#include "soc/soc_caps.h" + +// SOC_LCDCAM_I80_LCD_SUPPORTED, not SOC_LCD_I80_SUPPORTED: the classic ESP32 +// sets the latter for its I2S-LCD peripheral, which is NOT the LCD_CAM i80 bus +// esp_lcd drives here — compiling this body for the classic chip wired the +// driver onto it and hung its boot. Mirror the lcdLanes gate in +// platform_config.h. (esp_lcd headers below only exist where LCD_CAM does.) +#if SOC_LCDCAM_I80_LCD_SUPPORTED + +#include "esp_lcd_panel_io.h" +#include "esp_lcd_io_i80.h" +#include "esp_log.h" +#include "esp_heap_caps.h" +#include "driver/gpio.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +#include +#include // the transmit callback passed to the shared frame loopback +#include // std::nothrow + +namespace mm::platform { + +// Defined in platform_esp32_rmt.cpp — the plain-GPIO continuity pre-check the +// RMT loopback uses; the wire question is identical here. +namespace detail { bool loopbackJumperOk(uint8_t txGpio, uint8_t rxGpio); } + +namespace { + +static const char* LCD_TAG = "mm_lcd"; + +// 3 slots per WS2812 bit (the LcdSlots.h contract): 2.67 MHz pclk = 375 ns +// slots, "0" = 1 slot HIGH, "1" = 2 slots HIGH. 375 ns and not the lineage's +// usual 416 ns: newer WS2812B revisions spec T0H max ≈ 380 ns, and on a +// direct 3.3 V data line (no level shifter) a longer "0" pulse gets misread +// as "1" — the strip washes out white. 375 ns sits inside every revision's +// window; the 160 MHz LCD clock divides to it exactly (/60). +constexpr uint32_t kPclkHz = 2'666'666; + +struct LcdState { + esp_lcd_i80_bus_handle_t bus = nullptr; + esp_lcd_panel_io_handle_t io = nullptr; + SemaphoreHandle_t done = nullptr; + uint8_t* buf = nullptr; + size_t cap = 0; +}; + +// Done-callback: the GDMA stream finished — release the waiter. +bool lcdDoneCb(esp_lcd_panel_io_handle_t, esp_lcd_panel_io_event_data_t*, void* user) { + auto* st = static_cast(user); + BaseType_t high = pdFALSE; + xSemaphoreGiveFromISR(st->done, &high); + return high == pdTRUE; +} + +void destroyState(LcdState* st) { + if (!st) return; + if (st->io) esp_lcd_panel_io_del(st->io); + if (st->bus) esp_lcd_del_i80_bus(st->bus); + if (st->buf) heap_caps_free(st->buf); + if (st->done) vSemaphoreDelete(st->done); + delete st; +} + +// One bus + IO device + zeroed DMA buffer. Shared by the runtime init and the +// loopback's private 1-lane bus. +LcdState* createState(const uint16_t* dataPins, uint8_t laneCount, + uint16_t wrGpio, uint16_t dcGpio, size_t bufferBytes) { + auto* st = new (std::nothrow) LcdState(); + if (!st) return nullptr; + + esp_lcd_i80_bus_config_t busCfg = {}; + busCfg.dc_gpio_num = static_cast(dcGpio); + busCfg.wr_gpio_num = static_cast(wrGpio); + busCfg.clk_src = LCD_CLK_SRC_DEFAULT; + busCfg.bus_width = 8; + for (size_t i = 0; i < ESP_LCD_I80_BUS_WIDTH_MAX; i++) { + busCfg.data_gpio_nums[i] = GPIO_NUM_NC; + } + for (uint8_t i = 0; i < laneCount && i < 8; i++) { + busCfg.data_gpio_nums[i] = static_cast(dataPins[i]); + } + busCfg.max_transfer_bytes = bufferBytes; + busCfg.dma_burst_size = 64; + if (esp_lcd_new_i80_bus(&busCfg, &st->bus) != ESP_OK) { + destroyState(st); + return nullptr; + } + + st->done = xSemaphoreCreateBinary(); + if (!st->done) { + destroyState(st); + return nullptr; + } + + esp_lcd_panel_io_i80_config_t ioCfg = {}; + ioCfg.cs_gpio_num = GPIO_NUM_NC; // no chip select — we own the bus + ioCfg.pclk_hz = kPclkHz; + ioCfg.trans_queue_depth = 1; // synchronous full-frame: one in flight + ioCfg.on_color_trans_done = lcdDoneCb; + ioCfg.user_ctx = st; + ioCfg.lcd_cmd_bits = 0; // no command phase ever (tx_color cmd = -1) + ioCfg.lcd_param_bits = 0; + ioCfg.flags.pclk_idle_low = 1; // WR rests LOW like the data lines + if (esp_lcd_new_panel_io_i80(st->bus, &ioCfg, &st->io) != ESP_OK) { + destroyState(st); + return nullptr; + } + + // DMA-capable INTERNAL RAM with the bus's alignment — the esp_lcd helper + // handles both. Zeroed so the trailing latch pad (and any unwritten tail) + // holds the lines LOW. Internal (not PSRAM) for now: the i80 GDMA *can* burst + // from PSRAM (access_ext_mem), so moving big frames there to free scarce DRAM + // is a tracked follow-up (backlog § LCD/Parlio DMA frame buffer → PSRAM) — it + // needs the wider ext-mem alignment + on-hardware proof, so not done here. + st->buf = static_cast(esp_lcd_i80_alloc_draw_buffer( + st->io, bufferBytes, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL)); + if (!st->buf) { + destroyState(st); + return nullptr; + } + std::memset(st->buf, 0, bufferBytes); + st->cap = bufferBytes; + return st; +} + +} // namespace + +bool lcdWs2812Init(LcdWs2812Handle& h, const uint16_t* dataPins, uint8_t laneCount, + uint16_t wrGpio, uint16_t dcGpio, size_t bufferBytes) { + if (!dataPins || laneCount == 0 || bufferBytes == 0) return false; + // Keep the platform memory reserve intact — degrade (init failure → driver + // idles with a status error) rather than starve WiFi/HTTP of internal RAM. + if (heap_caps_get_free_size(MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL) + < bufferBytes + HEAP_RESERVE) { + return false; + } + LcdState* st = createState(dataPins, laneCount, wrGpio, dcGpio, bufferBytes); + if (!st) return false; + h.impl = st; + return true; +} + +uint8_t* lcdWs2812Buffer(const LcdWs2812Handle& h) { + auto* st = static_cast(h.impl); + return st ? st->buf : nullptr; +} + +size_t lcdWs2812BufferCapacity(const LcdWs2812Handle& h) { + auto* st = static_cast(h.impl); + return st ? st->cap : 0; +} + +bool lcdWs2812Transmit(LcdWs2812Handle& h, size_t bytes) { + auto* st = static_cast(h.impl); + if (!st || bytes == 0 || bytes > st->cap) return false; + // lcd_cmd = -1: no command phase — the transfer is one continuous GDMA + // data stream, gapless at the pclk rate from internal SRAM. + return esp_lcd_panel_io_tx_color(st->io, -1, st->buf, bytes) == ESP_OK; +} + +void lcdWs2812Wait(LcdWs2812Handle& h, uint32_t timeoutMs) { + auto* st = static_cast(h.impl); + if (!st) return; + // Finite timeout, same self-healing stance as rmtWs2812Wait: a timed-out + // frame is dropped and the driver re-encodes the whole frame next tick. + xSemaphoreTake(st->done, pdMS_TO_TICKS(timeoutMs)); +} + +void lcdWs2812Deinit(LcdWs2812Handle& h) { + auto* st = static_cast(h.impl); + if (!st) return; + destroyState(st); + h.impl = nullptr; +} + +// --------------------------------------------------------------------------- +// Loopback self-test: a private FULL-width bus (the i80 layer rejects NC data +// pins, so the driver's complete pin set is rebuilt) transmits the CALLER'S +// real frame — full size, real DMA descriptor chain, real latch pad — back to +// back like the render loop, while an RMT RX channel (rmtWs2812RxCapture with +// the DMA backend — transmitter-agnostic, reused from the RMT rig) captures +// the WHOLE frame off the jumpered rxGpio and verifies every bit. A short +// synthetic burst would miss exactly the failures a real frame hits +// (descriptor boundaries, sustained-rate stalls), so the test sends the +// genuine article. +// --------------------------------------------------------------------------- + +// The capture + bit-verify half is shared with the Parlio loopback in +// detail::captureAndVerifyFrame (platform_esp32_rmt.cpp); only the i80 transmit +// differs. Declared here so this TU can call it (same pattern as loopbackJumperOk). +namespace detail { +void captureAndVerifyFrame(uint16_t rxGpio, size_t frameBytes, size_t dataBytes, + uint8_t rowBits, uint32_t pclkHz, const char* tag, + const std::function& transmitOnce, + RmtLoopbackResult& r); +} + +RmtLoopbackResult lcdWs2812Loopback(const uint16_t* dataPins, uint8_t laneCount, + uint16_t wrGpio, uint16_t dcGpio, uint16_t rxGpio, + const uint8_t* frame, size_t frameBytes, + size_t dataBytes, uint8_t rowBits) { + RmtLoopbackResult r; + r.sent[0] = 0xA5; r.sent[1] = 0x00; r.sent[2] = 0xFF; // pattern in every row + if (!dataPins || laneCount == 0 || !frame || frameBytes == 0 + || dataBytes < 3 || dataBytes > frameBytes || rowBits < 8) return r; + const uint16_t txGpio = dataPins[0]; // lane 0 carries the pattern + + r.jumperDetected = detail::loopbackJumperOk(static_cast(txGpio), + static_cast(rxGpio)); + if (!r.jumperDetected) return r; + + // The continuity check above reset txGpio's GPIO matrix route; bus + // creation re-claims it. + LcdState* st = createState(dataPins, laneCount, wrGpio, dcGpio, frameBytes); + if (!st) { + ESP_LOGE(LCD_TAG, "loopback: private bus creation failed"); + return r; + } + std::memcpy(st->buf, frame, frameBytes); + + // The i80-specific transmit: ship one frame and wait for its done-callback. + // Everything else (capture, cadence, bit-verify) is the shared helper. + auto transmitOnce = [st, frameBytes]() { + // Loopback self-test path (not the render hot path): surface a failed + // enqueue or a done-callback timeout instead of letting it show up only as + // a later capture mismatch (same handling as the Parlio sibling). + const esp_err_t err = esp_lcd_panel_io_tx_color(st->io, -1, st->buf, frameBytes); + if (err != ESP_OK) { + ESP_LOGE(LCD_TAG, "loopback: tx enqueue failed (%s)", esp_err_to_name(err)); + return; + } + if (xSemaphoreTake(st->done, pdMS_TO_TICKS(1000)) != pdTRUE) + ESP_LOGE(LCD_TAG, "loopback: tx done-callback timed out"); + }; + detail::captureAndVerifyFrame(rxGpio, frameBytes, dataBytes, rowBits, kPclkHz, + LCD_TAG, transmitOnce, r); + destroyState(st); + return r; +} + +} // namespace mm::platform + +#else // !SOC_LCDCAM_I80_LCD_SUPPORTED — inert stubs so classic ESP32 links + +namespace mm::platform { + +bool lcdWs2812Init(LcdWs2812Handle&, const uint16_t*, uint8_t, uint16_t, uint16_t, + size_t) { + return false; +} +uint8_t* lcdWs2812Buffer(const LcdWs2812Handle&) { return nullptr; } +size_t lcdWs2812BufferCapacity(const LcdWs2812Handle&) { return 0; } +bool lcdWs2812Transmit(LcdWs2812Handle&, size_t) { return false; } +void lcdWs2812Wait(LcdWs2812Handle&, uint32_t) {} +void lcdWs2812Deinit(LcdWs2812Handle&) {} +RmtLoopbackResult lcdWs2812Loopback(const uint16_t*, uint8_t, uint16_t, uint16_t, + uint16_t, const uint8_t*, size_t, size_t, uint8_t) { + return {}; +} + +} // namespace mm::platform + +#endif // SOC_LCDCAM_I80_LCD_SUPPORTED diff --git a/src/platform/esp32/platform_esp32_parlio.cpp b/src/platform/esp32/platform_esp32_parlio.cpp new file mode 100644 index 00000000..a4129cc7 --- /dev/null +++ b/src/platform/esp32/platform_esp32_parlio.cpp @@ -0,0 +1,276 @@ +// Parlio (Parallel IO) WS2812 output — the peripheral half of the Parlio LED +// driver (ESP32-P4). The driver (src/light/drivers/ParlioLedDriver.h) does all +// the domain work: applies Correction and 3-slot-encodes every light into the +// DMA frame buffer (LcdSlots.h, the SAME encoder the LCD_CAM driver uses — one +// bus byte per slot, bit L = data line L). This file owns only the peripheral: +// the Parlio TX unit, the DMA frame buffer, transmit + wait. No domain logic. +// +// Design mirrors the LCD_CAM driver: the whole frame is pre-encoded into ONE +// buffer and sent as ONE autonomous DMA transfer (single-shot, NOT Parlio's +// loop-transmission mode) — once started no CPU work remains until the done +// callback, so there is no refill deadline for WiFi to miss. +// +// Simpler than i80: Parlio takes the data GPIOs directly (no sacrificial WR/DC +// lines — it generates the pixel clock internally) and the bus is always 8 +// lanes wide to match the encoder's 8-bit bus byte; lanes the driver doesn't +// use get GPIO -1 so they're simply not driven (no all-pins-required rule). +// +// Compiles on every ESP32 chip: everything is under SOC_PARLIO_SUPPORTED with +// inert stubs otherwise; the driver never calls in (platform::parlioLanes == 0). + +#include "platform/platform.h" + +#include "sdkconfig.h" +#include "soc/soc_caps.h" + +#if SOC_PARLIO_SUPPORTED + +#include "driver/parlio_tx.h" +#include "driver/gpio.h" // gpio_num_t / GPIO_NUM_NC for the unit's pin map +#include "esp_log.h" +#include "esp_heap_caps.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include +#include // the transmit callback passed to the shared frame loopback +#include // std::nothrow + +namespace mm::platform { + +namespace { + +static const char* PAR_TAG = "mm_parlio"; + +// The Parlio bus is always 8 data lines wide so each encoded bus byte maps +// byte→lane directly (the LcdSlots.h encoder writes 8-bit words, bit L = lane +// L). data_width must be a power of two ≤ SOC_PARLIO_TX_UNIT_MAX_DATA_WIDTH; +// 8 satisfies that. Unused lanes get GPIO -1. +constexpr size_t kBusWidth = 8; + +// WS2812 slot rate (375 ns @ 2.67 MHz), same value the driver passes at init — +// the loopback creates its own private unit and needs the constant directly. +constexpr uint32_t kPclkHz = 2'666'666; + +struct ParlioState { + parlio_tx_unit_handle_t unit = nullptr; + SemaphoreHandle_t done = nullptr; + uint8_t* buf = nullptr; + size_t cap = 0; +}; + +// Done-callback: the DMA transfer finished — release the waiter. +bool IRAM_ATTR parlioDoneCb(parlio_tx_unit_handle_t, const parlio_tx_done_event_data_t*, + void* user) { + auto* st = static_cast(user); + BaseType_t high = pdFALSE; + xSemaphoreGiveFromISR(st->done, &high); + return high == pdTRUE; +} + +void destroyState(ParlioState* st) { + if (!st) return; + if (st->unit) { + parlio_tx_unit_disable(st->unit); + parlio_del_tx_unit(st->unit); + } + if (st->buf) heap_caps_free(st->buf); + if (st->done) vSemaphoreDelete(st->done); + delete st; +} + +// One TX unit + zeroed DMA buffer. pclkHz is the WS2812 slot rate (2.67 MHz). +ParlioState* createState(const uint16_t* dataPins, uint8_t laneCount, + uint32_t pclkHz, size_t bufferBytes) { + auto* st = new (std::nothrow) ParlioState(); + if (!st) return nullptr; + + parlio_tx_unit_config_t cfg = {}; + cfg.clk_src = PARLIO_CLK_SRC_DEFAULT; // PLL_F160M → /60 = 2.67 MHz + cfg.clk_in_gpio_num = GPIO_NUM_NC; // internal clock, not external + cfg.output_clk_freq_hz = pclkHz; + cfg.data_width = kBusWidth; + for (size_t i = 0; i < kBusWidth; i++) cfg.data_gpio_nums[i] = GPIO_NUM_NC; + for (uint8_t i = 0; i < laneCount && i < kBusWidth; i++) + cfg.data_gpio_nums[i] = static_cast(dataPins[i]); + cfg.clk_out_gpio_num = GPIO_NUM_NC; // WS2812 ignores the clock line + cfg.valid_gpio_num = GPIO_NUM_NC; + cfg.trans_queue_depth = 1; // single full-frame transfer + cfg.max_transfer_size = bufferBytes; + cfg.dma_burst_size = 64; + cfg.sample_edge = PARLIO_SAMPLE_EDGE_POS; + cfg.bit_pack_order = PARLIO_BIT_PACK_ORDER_MSB; + if (parlio_new_tx_unit(&cfg, &st->unit) != ESP_OK) { + destroyState(st); + return nullptr; + } + + st->done = xSemaphoreCreateBinary(); + if (!st->done) { destroyState(st); return nullptr; } + parlio_tx_event_callbacks_t cbs = {}; + cbs.on_trans_done = parlioDoneCb; + if (parlio_tx_unit_register_event_callbacks(st->unit, &cbs, st) != ESP_OK) { + destroyState(st); + return nullptr; + } + if (parlio_tx_unit_enable(st->unit) != ESP_OK) { destroyState(st); return nullptr; } + + // DMA-capable INTERNAL RAM: Parlio streams from internal SRAM at full rate + // (the same constraint as the LCD driver — platform::alloc prefers PSRAM and + // is wrong here). Zeroed so the trailing latch pad holds lines LOW. Internal + // for now even though the Parlio GDMA *can* burst from PSRAM (access_ext_mem): + // moving big frames there to free scarce DRAM is a tracked follow-up (backlog + // § LCD/Parlio DMA frame buffer → PSRAM), needing the wider ext-mem alignment + // (not this fixed 64) + on-hardware proof, so not done here. + st->buf = static_cast(heap_caps_aligned_alloc( + 64, bufferBytes, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL)); + if (!st->buf) { destroyState(st); return nullptr; } + std::memset(st->buf, 0, bufferBytes); + st->cap = bufferBytes; + return st; +} + +} // namespace + +bool parlioWs2812Init(ParlioWs2812Handle& h, const uint16_t* dataPins, + uint8_t laneCount, uint32_t pclkHz, size_t bufferBytes) { + if (!dataPins || laneCount == 0 || bufferBytes == 0) return false; + // Keep the platform memory reserve intact — degrade (init failure → driver + // idles with a status error) rather than starve the system of internal RAM. + if (heap_caps_get_free_size(MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL) + < bufferBytes + HEAP_RESERVE) { + return false; + } + ParlioState* st = createState(dataPins, laneCount, pclkHz, bufferBytes); + if (!st) return false; + h.impl = st; + return true; +} + +uint8_t* parlioWs2812Buffer(const ParlioWs2812Handle& h) { + auto* st = static_cast(h.impl); + return st ? st->buf : nullptr; +} + +size_t parlioWs2812BufferCapacity(const ParlioWs2812Handle& h) { + auto* st = static_cast(h.impl); + return st ? st->cap : 0; +} + +bool parlioWs2812Transmit(ParlioWs2812Handle& h, size_t bytes) { + auto* st = static_cast(h.impl); + if (!st || bytes == 0 || bytes > st->cap) return false; + parlio_transmit_config_t xcfg = {}; + xcfg.idle_value = 0; // lines rest LOW between/after the frame (the latch) + // payload length is in BITS; the buffer is bytes × 8 lanes-worth of slots. + return parlio_tx_unit_transmit(st->unit, st->buf, bytes * 8, &xcfg) == ESP_OK; +} + +void parlioWs2812Wait(ParlioWs2812Handle& h, uint32_t timeoutMs) { + auto* st = static_cast(h.impl); + if (!st) return; + // Finite timeout, self-healing: a timed-out frame is dropped and the whole + // frame re-encoded next tick (same stance as rmt/lcd Wait). + parlio_tx_unit_wait_all_done(st->unit, static_cast(timeoutMs)); +} + +void parlioWs2812Deinit(ParlioWs2812Handle& h) { + auto* st = static_cast(h.impl); + if (!st) return; + destroyState(st); + h.impl = nullptr; +} + +// --------------------------------------------------------------------------- +// Loopback self-test: a private Parlio TX unit on the driver's data pins +// transmits the CALLER'S real frame — full size, real DMA transfer, real latch +// pad — back to back like the render loop, while an RMT RX channel +// (rmtWs2812RxCapture with the DMA backend — transmitter-agnostic, reused from +// the RMT/LCD rigs) captures the WHOLE frame off the jumpered rxGpio and +// verifies every bit. This is the LCD loopback (platform_esp32_lcd.cpp) with +// the i80 transmit swapped for Parlio's: no WR/DC pins, and the payload goes +// out via parlio_tx_unit_transmit (length in BITS) instead of +// esp_lcd_panel_io_tx_color. The RX capture half is byte-for-byte identical — +// the wire signal is the same WS2812 the encoder produced for either bus. +// --------------------------------------------------------------------------- + +// loopbackJumperOk + captureAndVerifyFrame live in platform_esp32_rmt.cpp (the +// shared continuity check and the shared capture+bit-verify all three loopback +// rigs reuse); declared here so this TU can call them. +namespace detail { +bool loopbackJumperOk(uint8_t txGpio, uint8_t rxGpio); +void captureAndVerifyFrame(uint16_t rxGpio, size_t frameBytes, size_t dataBytes, + uint8_t rowBits, uint32_t pclkHz, const char* tag, + const std::function& transmitOnce, + RmtLoopbackResult& r); +} + +RmtLoopbackResult parlioWs2812Loopback(const uint16_t* dataPins, uint8_t laneCount, + uint16_t rxGpio, const uint8_t* frame, + size_t frameBytes, size_t dataBytes, + uint8_t rowBits) { + RmtLoopbackResult r; + r.sent[0] = 0xA5; r.sent[1] = 0x00; r.sent[2] = 0xFF; // pattern in every row + if (!dataPins || laneCount == 0 || !frame || frameBytes == 0 + || dataBytes < 3 || dataBytes > frameBytes || rowBits < 8) return r; + const uint16_t txGpio = dataPins[0]; // lane 0 carries the pattern + + r.jumperDetected = detail::loopbackJumperOk(static_cast(txGpio), + static_cast(rxGpio)); + if (!r.jumperDetected) return r; + + // The continuity check above reset txGpio's GPIO matrix route; the TX unit + // creation below re-claims it. + ParlioState* st = createState(dataPins, laneCount, kPclkHz, frameBytes); + if (!st) { + ESP_LOGE(PAR_TAG, "loopback: private TX unit creation failed"); + return r; + } + std::memcpy(st->buf, frame, frameBytes); + + // The Parlio-specific transmit: ship one frame (length in BITS, not bytes) + // and wait for its done-callback. Everything else (capture, cadence, bit- + // verify) is the shared helper. + parlio_transmit_config_t xcfg = {}; + xcfg.idle_value = 0; // lines rest LOW between frames (the latch) + auto transmitOnce = [st, frameBytes, &xcfg]() { + // Loopback self-test path (not the render hot path): a failed enqueue or a + // done-callback timeout would otherwise be silent and just surface later as + // a capture mismatch — log it so the real cause is visible in the verdict. + const esp_err_t err = parlio_tx_unit_transmit(st->unit, st->buf, + frameBytes * 8, &xcfg); + if (err != ESP_OK) { + ESP_LOGE(PAR_TAG, "loopback: tx enqueue failed (%s)", esp_err_to_name(err)); + return; // nothing to wait for + } + if (xSemaphoreTake(st->done, pdMS_TO_TICKS(1000)) != pdTRUE) + ESP_LOGE(PAR_TAG, "loopback: tx done-callback timed out"); + }; + detail::captureAndVerifyFrame(rxGpio, frameBytes, dataBytes, rowBits, kPclkHz, + PAR_TAG, transmitOnce, r); + destroyState(st); + return r; +} + +} // namespace mm::platform + +#else // !SOC_PARLIO_SUPPORTED — inert stubs so classic ESP32 / S3 link + +namespace mm::platform { + +bool parlioWs2812Init(ParlioWs2812Handle&, const uint16_t*, uint8_t, uint32_t, size_t) { + return false; +} +uint8_t* parlioWs2812Buffer(const ParlioWs2812Handle&) { return nullptr; } +size_t parlioWs2812BufferCapacity(const ParlioWs2812Handle&) { return 0; } +bool parlioWs2812Transmit(ParlioWs2812Handle&, size_t) { return false; } +void parlioWs2812Wait(ParlioWs2812Handle&, uint32_t) {} +void parlioWs2812Deinit(ParlioWs2812Handle&) {} +RmtLoopbackResult parlioWs2812Loopback(const uint16_t*, uint8_t, uint16_t, + const uint8_t*, size_t, size_t, uint8_t) { + return {}; +} + +} // namespace mm::platform + +#endif // SOC_PARLIO_SUPPORTED diff --git a/src/platform/esp32/platform_esp32_rmt.cpp b/src/platform/esp32/platform_esp32_rmt.cpp new file mode 100644 index 00000000..209f0950 --- /dev/null +++ b/src/platform/esp32/platform_esp32_rmt.cpp @@ -0,0 +1,538 @@ +// RMT WS2812 LED output — the peripheral half of the LED driver. +// +// The driver (src/light/drivers/RmtLedDriver.h) does all the domain work: +// applies Correction and encodes each pixel into RMT symbols. This file owns +// only the peripheral — channel setup, the copy-encoder that streams the +// pre-built symbols, transmit + wait, and the RX side the on-device loopback +// test uses. No domain logic here. +// +// Pre-encoded path: the driver hands us a flat array of WS2812 symbols already +// in rmt_symbol_word_t layout (our makeRmtSymbol() in RmtSymbol.h packs exactly +// that 32-bit format), so the TX path uses a *copy* encoder — it just DMAs the +// bytes out, no per-call symbol generation. + +#include "platform/platform.h" + +#include "driver/rmt_tx.h" +#include "driver/rmt_rx.h" +#include "driver/rmt_encoder.h" +#include "driver/gpio.h" // continuity pre-check in the loopback self-test +#include "soc/soc_caps.h" // SOC_RMT_MEM_WORDS_PER_CHANNEL (64 classic, 48 S3) +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" +#include "rom/ets_sys.h" // ets_delay_us for the reset gap + +#include "esp_heap_caps.h" // capture buffer alloc for the shared frame loopback +#include "esp_timer.h" // timed first transmit +#include "esp_log.h" + +#include +#include +#include // the transmit callback the shared frame loopback takes +#include // std::nothrow + +namespace mm::platform { + +namespace { + +// Per-channel peripheral state, hidden behind RmtWs2812Handle::impl so the +// domain header never sees an ESP type. One TX channel + the copy encoder it +// streams symbols through, both allocated once at init. +struct RmtTxState { + rmt_channel_handle_t channel = nullptr; + rmt_encoder_handle_t encoder = nullptr; + uint32_t resolutionHz = 0; +}; + +} // namespace + +bool rmtWs2812Init(RmtWs2812Handle& h, uint8_t gpio, uint32_t resolutionHz, bool invert) { + auto* st = new (std::nothrow) RmtTxState(); + if (!st) return false; + + rmt_tx_channel_config_t txCfg = {}; + txCfg.gpio_num = static_cast(gpio); + txCfg.clk_src = RMT_CLK_SRC_DEFAULT; + txCfg.resolution_hz = resolutionHz; + // Two memory blocks of symbols ping-pong so the DMA-less channel can refill + // while sending — the classic anti-glitch shape. The per-channel block size + // is a chip fact (64 words classic, 48 on the S3 — a hardcoded 64 makes + // rmt_new_tx_channel reject S3); the copy encoder streams from our buffer + // regardless. + txCfg.mem_block_symbols = SOC_RMT_MEM_WORDS_PER_CHANNEL; + txCfg.trans_queue_depth = 4; + txCfg.flags.invert_out = invert ? 1 : 0; + + if (rmt_new_tx_channel(&txCfg, &st->channel) != ESP_OK) { + delete st; + return false; + } + + rmt_copy_encoder_config_t copyCfg = {}; + if (rmt_new_copy_encoder(©Cfg, &st->encoder) != ESP_OK) { + rmt_del_channel(st->channel); + delete st; + return false; + } + + if (rmt_enable(st->channel) != ESP_OK) { + rmt_del_encoder(st->encoder); + rmt_del_channel(st->channel); + delete st; + return false; + } + + st->resolutionHz = resolutionHz; + h.impl = st; + return true; +} + +uint32_t rmtWs2812Resolution(const RmtWs2812Handle& h) { + auto* st = static_cast(h.impl); + return st ? st->resolutionHz : 0; +} + +bool rmtWs2812Transmit(RmtWs2812Handle& h, const uint32_t* symbols, size_t symbolCount) { + auto* st = static_cast(h.impl); + if (!st || !symbols || symbolCount == 0) return false; + + rmt_transmit_config_t txCfg = {}; + txCfg.loop_count = 0; // single shot, no hardware loop + + // Our symbols are already rmt_symbol_word_t-shaped; the copy encoder takes a + // byte size. This only *starts* the transfer — channels started back-to-back + // clock out concurrently, which is what makes a multi-pin frame cost the + // longest strand instead of the sum. The caller pairs this with + // rmtWs2812Wait and owns the inter-frame latch after the last wait. + return rmt_transmit(st->channel, st->encoder, symbols, + symbolCount * sizeof(uint32_t), &txCfg) == ESP_OK; +} + +void rmtWs2812Wait(RmtWs2812Handle& h, uint32_t timeoutMs) { + auto* st = static_cast(h.impl); + if (!st) return; + // Finite timeout so a wedged DMA can't hang the render tick forever. Even the + // longest realistic frame (thousands of pixels) clocks out well under 1 s; a + // timeout here means the peripheral is stuck, and the driver re-encodes the + // whole frame next tick anyway, so a dropped frame self-heals. + // + // We deliberately do NOT cancel a timed-out transfer with rmt_disable(): on + // classic ESP32, rmt_disable() while a transmission is still active triggers an + // interrupt-WDT panic (espressif/esp-idf#17692, classic-only — S3/C6/P4 are + // unaffected). A panic is a worse failure than a dropped frame, so we leave the + // stuck transfer alone. It self-heals safely: the next tick re-encodes symbols_ + // and calls rmt_transmit again; if the channel is still busy, rmt_transmit + // returns an error, rmtWs2812Transmit returns false, and RmtLedDriver::loop() + // skips waiting on that channel (its started[] guard) — no crash, no corruption. + rmt_tx_wait_all_done(st->channel, timeoutMs); +} + +void rmtWs2812Deinit(RmtWs2812Handle& h) { + auto* st = static_cast(h.impl); + if (!st) return; + if (st->channel) { + rmt_disable(st->channel); + rmt_del_channel(st->channel); + } + if (st->encoder) rmt_del_encoder(st->encoder); + delete st; + h.impl = nullptr; +} + +// --------------------------------------------------------------------------- +// RX loopback capture — on-device test only. Opens a one-shot RX channel on the +// jumpered pin, captures raw pulse symbols, returns how many landed. The test +// decodes those symbols back to bytes and asserts == sent. +// --------------------------------------------------------------------------- + +namespace { + +// done-callback hands the received symbol count to the waiting capture call via +// a 1-deep queue. IRAM so it survives a cache-disabled window. +struct RxDone { size_t numSymbols; }; + +bool IRAM_ATTR rmtRxDoneCb(rmt_channel_handle_t, const rmt_rx_done_event_data_t* edata, + void* user) { + QueueHandle_t q = static_cast(user); + RxDone d = { edata->num_symbols }; + BaseType_t high = pdFALSE; + xQueueSendFromISR(q, &d, &high); + return high == pdTRUE; +} + +} // namespace + +size_t rmtWs2812RxCapture(uint8_t gpio, uint32_t resolutionHz, + uint32_t* outSymbols, size_t maxSymbols, uint32_t timeoutMs) { + if (!outSymbols || maxSymbols == 0) return 0; + + rmt_rx_channel_config_t rxCfg = {}; + rxCfg.gpio_num = static_cast(gpio); + rxCfg.clk_src = RMT_CLK_SRC_DEFAULT; + rxCfg.resolution_hz = resolutionHz; + // The RX channel's internal memory block must be even and >= one hardware + // block (IDF requirement; 64 words classic, 48 on the S3 — a hardcoded 64 + // would silently claim part of a second S3 channel's memory). Round + // maxSymbols up to that floor; the actual capture buffer (outSymbols / + // maxSymbols) is separate and can be smaller. + size_t memBlock = static_cast(maxSymbols); + if (memBlock < SOC_RMT_MEM_WORDS_PER_CHANNEL) memBlock = SOC_RMT_MEM_WORDS_PER_CHANNEL; + if (memBlock & 1) memBlock++; +#if SOC_RMT_SUPPORT_DMA + // A capture larger than one hardware block (whole-frame captures, e.g. the + // LCD loopback's full-frame check) uses the DMA backend, which can stream + // an arbitrarily large mem_block. Caller's buffer must then be DMA-capable + // internal RAM. + rxCfg.flags.with_dma = maxSymbols > SOC_RMT_MEM_WORDS_PER_CHANNEL; +#else + // No RMT DMA (classic ESP32): mem_block_symbols larger than one hardware + // channel silently claims neighbouring channels' memory and fails to + // allocate ("no free rx channels"). Cap to a single channel — the caller + // gets at most one channel's worth of symbols per capture. A whole-frame + // check on such a chip must therefore use a frame that fits one channel + // (the frame loopback sizes itself to maxLaneLights accordingly). + if (memBlock > SOC_RMT_MEM_WORDS_PER_CHANNEL) + memBlock = SOC_RMT_MEM_WORDS_PER_CHANNEL; +#endif + rxCfg.mem_block_symbols = memBlock; + + rmt_channel_handle_t rxChan = nullptr; + if (rmt_new_rx_channel(&rxCfg, &rxChan) != ESP_OK) return 0; + + QueueHandle_t q = xQueueCreate(1, sizeof(RxDone)); + if (!q) { rmt_del_channel(rxChan); return 0; } + + rmt_rx_event_callbacks_t cbs = {}; + cbs.on_recv_done = rmtRxDoneCb; + rmt_rx_register_event_callbacks(rxChan, &cbs, q); + + // Accept WS2812 pulse widths: anything from a fraction of T0H up to well past + // a bit cell, so glitches are filtered but real 0/1 pulses pass. + rmt_receive_config_t rcfg = {}; + rcfg.signal_range_min_ns = 100; // shorter than any real WS2812 edge + rcfg.signal_range_max_ns = 100000; // longer than a bit cell; ends the frame + + size_t got = 0; + if (rmt_enable(rxChan) == ESP_OK) { + // Once enabled, the channel must be disabled before delete — even if + // rmt_receive or the wait fails — or rmt_del_channel rejects it. + if (rmt_receive(rxChan, outSymbols, maxSymbols * sizeof(uint32_t), &rcfg) == ESP_OK) { + RxDone d = {}; + if (xQueueReceive(q, &d, pdMS_TO_TICKS(timeoutMs)) == pdTRUE) { + got = d.numSymbols; + } + } + rmt_disable(rxChan); + } + + vQueueDelete(q); + rmt_del_channel(rxChan); + return got; +} + +// --------------------------------------------------------------------------- +// Loopback self-test (runnable from the live firmware via RmtLedDriver's +// loopbackTest control). TX a known WS2812 pattern on txGpio, capture it back on +// rxGpio (user jumpers them), decode, compare. The WS2812 symbol build is inlined +// here (trivial — two symbol shapes) so the platform stays self-contained and +// src/light/ keeps no platform dependency. +// --------------------------------------------------------------------------- + +namespace { + +constexpr uint32_t kLoopbackResHz = 40'000'000; // 25 ns/tick, same as the driver +constexpr uint16_t kT0H = 14, kT1H = 28, kPeriod = 50; // 350/700/1250 ns in ticks + +} // namespace + +namespace detail { + +// Plain-GPIO continuity check: drive tx, read rx. Separates "wire wrong" from +// "RMT/LCD wrong" so a failed jumper is reported clearly. Shared with the LCD +// loopback in platform_esp32_lcd.cpp (declared there), hence not anonymous. +bool loopbackJumperOk(uint8_t txGpio, uint8_t rxGpio) { + gpio_set_direction(static_cast(txGpio), GPIO_MODE_OUTPUT); + gpio_set_direction(static_cast(rxGpio), GPIO_MODE_INPUT); + gpio_set_pull_mode(static_cast(rxGpio), GPIO_PULLDOWN_ONLY); + gpio_set_level(static_cast(txGpio), 1); + ets_delay_us(2000); + int hi = gpio_get_level(static_cast(rxGpio)); + gpio_set_level(static_cast(txGpio), 0); + ets_delay_us(2000); + int lo = gpio_get_level(static_cast(rxGpio)); + gpio_reset_pin(static_cast(txGpio)); + gpio_reset_pin(static_cast(rxGpio)); + return hi == 1 && lo == 0; +} + +// Shared frame-capture + bit-verify for the two parallel LED loopbacks (LCD_CAM +// i80 and Parlio). They differ only in the transmit call (esp_lcd_panel_io_tx_color +// vs parlio_tx_unit_transmit) and the private-bus state type; everything else — +// the capture buffer, the RX task, the timed-first/back-to-back transmit cadence, +// and the whole per-bit verification — was byte-for-byte identical, so it lives +// here once. The caller has already done the jumper pre-check and built its +// private TX bus on the data pins; it passes `transmitOnce` (transmit the frame +// AND wait for its done-callback) and the params needed to size the capture and +// log the granted clock. `r` is filled in place (jumperDetected already set). +void captureAndVerifyFrame(uint16_t rxGpio, size_t frameBytes, size_t dataBytes, + uint8_t rowBits, uint32_t pclkHz, const char* tag, + const std::function& transmitOnce, + RmtLoopbackResult& r) { + // Capture at 40 MHz: a slot is 15 ticks, so "0" ≈ 15 and "1" ≈ 30 high ticks + // — threshold midway at 25. One symbol per WS2812 bit; the frame's zeroed + // latch pad is the >100 µs idle that ends the capture. + constexpr uint32_t kCapResHz = 40'000'000; + const size_t kBits = dataBytes / 3; + const size_t capMax = kBits + 16; + auto* rxSymbols = static_cast(heap_caps_aligned_alloc( + 64, capMax * sizeof(uint32_t), MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL)); + if (!rxSymbols) { + ESP_LOGE(tag, "loopback: capture buffer alloc failed (%u B)", + (unsigned)(capMax * sizeof(uint32_t))); + return; + } + + struct Cap { + uint8_t rxGpio; uint32_t* buf; size_t max; + volatile size_t got = 0; volatile bool done = false; + } cap{static_cast(rxGpio), rxSymbols, capMax}; + auto rxTask = [](void* arg) { + auto* c = static_cast(arg); + c->got = rmtWs2812RxCapture(c->rxGpio, kCapResHz, c->buf, c->max, 1000); + c->done = true; + vTaskDelete(nullptr); + }; + if (xTaskCreate(rxTask, "lblb", 4096, &cap, 5, nullptr) == pdPASS) { + vTaskDelay(pdMS_TO_TICKS(50)); + // First transmit timed — the wall time of a known byte count confirms the + // granted pixel clock matches the configured slot rate (the bus driver + // doesn't expose the granted clock directly). + { + const int64_t t0 = esp_timer_get_time(); + transmitOnce(); + const int64_t dt = esp_timer_get_time() - t0; + ESP_LOGI(tag, "loopback: %u bytes in %lld us (expect ~%u us at %u Hz)", + (unsigned)frameBytes, (long long)dt, + (unsigned)(frameBytes * 1000000ull / pclkHz), (unsigned)pclkHz); + } + // Back-to-back frames, exactly the render loop's transmit/wait cadence. + for (int i = 0; i < 100 && !cap.done; i++) transmitOnce(); + for (int i = 0; i < 200 && !cap.done; i++) vTaskDelay(pdMS_TO_TICKS(10)); + } + ESP_LOGI(tag, "loopback: rx captured %u symbols (need %u), idle rx level=%d", + (unsigned)cap.got, (unsigned)kBits, + gpio_get_level(static_cast(rxGpio))); + + if (cap.done && cap.got >= kBits) { + // Verify EVERY bit of the frame against the per-row pattern (r.sent[], + // zero-padded for RGBW rows), not just the first light. + size_t mismatch = SIZE_MAX; + uint16_t minH[2] = {0x7FFF, 0x7FFF}, maxH[2] = {0, 0}; + for (size_t b = 0; b < kBits; b++) { + const uint16_t high = static_cast(rxSymbols[b] & 0x7FFF); + const uint8_t bit = (high >= 25) ? 1 : 0; + if (high < minH[bit]) minH[bit] = high; + if (high > maxH[bit]) maxH[bit] = high; + const uint8_t rowPos = static_cast(b % rowBits); + const uint8_t expByte = (rowPos / 8u) < 3 ? r.sent[rowPos / 8u] : 0x00; + const uint8_t exp = (expByte >> (7 - (rowPos & 7))) & 1u; + if (bit != exp && mismatch == SIZE_MAX) mismatch = b; + } + // r.got[] reports the row holding the first mismatch (row 0 when clean). + const size_t rowStart = (mismatch == SIZE_MAX) + ? 0 : mismatch - (mismatch % rowBits); + for (size_t b = rowStart; b < rowStart + 24 && b < cap.got; b++) { + const uint8_t bit = ((rxSymbols[b] & 0x7FFF) >= 25) ? 1 : 0; + r.got[(b - rowStart) / 8] = + static_cast((r.got[(b - rowStart) / 8] << 1) | bit); + } + r.pass = mismatch == SIZE_MAX; + r.bitsChecked = static_cast(kBits); + r.firstBadBit = (mismatch == SIZE_MAX) ? static_cast(kBits) + : static_cast(mismatch); + ESP_LOGI(tag, "loopback: high ticks — 0-bits %u..%u, 1-bits %u..%u (25ns/tick)", + (unsigned)minH[0], (unsigned)maxH[0], (unsigned)minH[1], (unsigned)maxH[1]); + if (!r.pass) { + ESP_LOGE(tag, "loopback: first bad bit %u (light %u, bit-in-row %u)", + (unsigned)mismatch, (unsigned)(mismatch / rowBits), + (unsigned)(mismatch % rowBits)); + } + } + heap_caps_free(rxSymbols); +} + +} // namespace detail + +RmtLoopbackResult rmtWs2812Loopback(uint8_t txGpio, uint8_t rxGpio) { + RmtLoopbackResult r; + r.sent[0] = 0xA5; r.sent[1] = 0x00; r.sent[2] = 0xFF; // recognisable pattern + + r.jumperDetected = detail::loopbackJumperOk(txGpio, rxGpio); + if (!r.jumperDetected) return r; // no point running RMT through a dead wire + + // Build 24 symbols (3 bytes × 8 bits, MSB-first) for the pattern. + const uint32_t sym0 = static_cast(kT0H) | (1u << 15) + | (static_cast(kPeriod - kT0H) << 16); + const uint32_t sym1 = static_cast(kT1H) | (1u << 15) + | (static_cast(kPeriod - kT1H) << 16); + constexpr size_t kBits = 24; + uint32_t txSymbols[kBits]; + size_t s = 0; + for (int b = 0; b < 3; b++) + for (int bit = 7; bit >= 0; bit--) + txSymbols[s++] = (r.sent[b] & (1u << bit)) ? sym1 : sym0; + + RmtWs2812Handle tx; + if (!rmtWs2812Init(tx, txGpio, kLoopbackResHz, /*invert=*/false)) return r; + + // RX must be listening while we transmit; run the (blocking) capture in a task + // and resend the short frame until the receiver latches one or we give up. + constexpr size_t kCapMax = kBits + 8; + static uint32_t rxSymbols[kCapMax]; + // Pass rxGpio through the arg struct (the task fn is a plain C pointer — no captures). + struct Cap { uint8_t rxGpio; volatile size_t got = 0; volatile bool done = false; } cap{rxGpio}; + auto rxTask = [](void* arg) { + auto* c = static_cast(arg); + c->got = rmtWs2812RxCapture(c->rxGpio, kLoopbackResHz, rxSymbols, kCapMax, 1000); + c->done = true; + vTaskDelete(nullptr); + }; + if (xTaskCreate(rxTask, "rmtlb", 4096, &cap, 5, nullptr) == pdPASS) { + vTaskDelay(pdMS_TO_TICKS(50)); + for (int i = 0; i < 50 && !cap.done; i++) { + rmtWs2812Transmit(tx, txSymbols, kBits); + rmtWs2812Wait(tx, 1000); + ets_delay_us(300); // inter-frame latch + vTaskDelay(pdMS_TO_TICKS(10)); + } + for (int i = 0; i < 200 && !cap.done; i++) vTaskDelay(pdMS_TO_TICKS(10)); + } + rmtWs2812Deinit(tx); + + if (cap.done && cap.got >= kBits) { + // Decode the first 24 captured symbols → bytes (HIGH closer to T1H = 1). + for (size_t b = 0; b < kBits; b++) { + uint16_t high = static_cast(rxSymbols[b] & 0x7FFF); + uint8_t bit = (high >= ((kT0H + kT1H) / 2)) ? 1 : 0; + r.got[b / 8] = static_cast((r.got[b / 8] << 1) | bit); + } + r.pass = (r.got[0] == r.sent[0] && r.got[1] == r.sent[1] && r.got[2] == r.sent[2]); + } + r.bitsChecked = static_cast(kBits); + r.firstBadBit = r.pass ? static_cast(kBits) : 0; + return r; +} + +// Whole-frame variant: transmit a real `lights`-light frame back to back and +// bit-verify the WHOLE capture. The per-light pattern is 0xA5/0x00/0xFF (the +// sent[] bytes), zero-padded for any 4th (white) channel, repeated for every +// light. Unlike the 24-bit burst above, this drives the sustained DMA path and +// a long wire under whatever RF the device is doing — so it catches the +// frame-rate corruption and interference the short test is blind to. +RmtLoopbackResult rmtWs2812LoopbackFrame(uint8_t txGpio, uint8_t rxGpio, + uint16_t lights, uint8_t channels) { + RmtLoopbackResult r; + r.sent[0] = 0xA5; r.sent[1] = 0x00; r.sent[2] = 0xFF; + if (lights == 0 || channels < 3 || channels > 4) return r; + + r.jumperDetected = detail::loopbackJumperOk(txGpio, rxGpio); + if (!r.jumperDetected) return r; + + const uint32_t sym0 = static_cast(kT0H) | (1u << 15) + | (static_cast(kPeriod - kT0H) << 16); + const uint32_t sym1 = static_cast(kT1H) | (1u << 15) + | (static_cast(kPeriod - kT1H) << 16); + const uint8_t bitsPerLight = static_cast(channels * 8); +#if !SOC_RMT_SUPPORT_DMA + // No RMT DMA (classic ESP32): the RX capture can hold at most one hardware + // channel's symbols, so cap the verified frame to what fits whole lights in + // that block. The frame is still transmitted back to back (the sustained- + // output stress that exposes RF interference); we just verify a prefix that + // the no-DMA receiver can actually capture. + const uint16_t maxLights = + static_cast(SOC_RMT_MEM_WORDS_PER_CHANNEL / bitsPerLight); + if (lights > maxLights) lights = maxLights ? maxLights : 1; +#endif + const size_t kBits = static_cast(lights) * bitsPerLight; + + // One real frame's worth of symbols, DMA-capable internal RAM (the same + // place the driver's own symbol buffer lives). Off the hot path — this is + // a control-driven self-test. + auto* txSymbols = static_cast(heap_caps_malloc( + kBits * sizeof(uint32_t), MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL)); + const size_t capMax = kBits + 16; + auto* rxSymbols = static_cast(heap_caps_aligned_alloc( + 64, capMax * sizeof(uint32_t), MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL)); + if (!txSymbols || !rxSymbols) { + heap_caps_free(txSymbols); + heap_caps_free(rxSymbols); + return r; + } + size_t s = 0; + for (uint16_t light = 0; light < lights; light++) + for (uint8_t ch = 0; ch < channels; ch++) { + const uint8_t byte = ch < 3 ? r.sent[ch] : 0x00; + for (int bit = 7; bit >= 0; bit--) + txSymbols[s++] = (byte & (1u << bit)) ? sym1 : sym0; + } + + RmtWs2812Handle tx; + if (!rmtWs2812Init(tx, txGpio, kLoopbackResHz, /*invert=*/false)) { + heap_caps_free(txSymbols); + heap_caps_free(rxSymbols); + return r; + } + + struct Cap { + uint8_t rxGpio; uint32_t* buf; size_t max; + volatile size_t got = 0; volatile bool done = false; + } cap{rxGpio, rxSymbols, capMax}; + auto rxTask = [](void* arg) { + auto* c = static_cast(arg); + c->got = rmtWs2812RxCapture(c->rxGpio, kLoopbackResHz, c->buf, c->max, 1000); + c->done = true; + vTaskDelete(nullptr); + }; + if (xTaskCreate(rxTask, "rmtlbf", 4096, &cap, 5, nullptr) == pdPASS) { + vTaskDelay(pdMS_TO_TICKS(50)); + // Back-to-back frames, the render loop's cadence. The capture latches + // one whole frame; we keep resending so it can't miss the window. + for (int i = 0; i < 100 && !cap.done; i++) { + rmtWs2812Transmit(tx, txSymbols, kBits); + rmtWs2812Wait(tx, 1000); + ets_delay_us(300); // inter-frame WS2812 latch + } + for (int i = 0; i < 200 && !cap.done; i++) vTaskDelay(pdMS_TO_TICKS(10)); + } + rmtWs2812Deinit(tx); + + if (cap.done && cap.got >= kBits) { + size_t mismatch = SIZE_MAX; + for (size_t b = 0; b < kBits; b++) { + const uint16_t high = static_cast(rxSymbols[b] & 0x7FFF); + const uint8_t bit = (high >= ((kT0H + kT1H) / 2)) ? 1 : 0; + const uint8_t pos = static_cast(b % bitsPerLight); + const uint8_t expByte = (pos / 8u) < 3 ? r.sent[pos / 8u] : 0x00; + const uint8_t exp = (expByte >> (7 - (pos & 7))) & 1u; + if (bit != exp && mismatch == SIZE_MAX) mismatch = b; + } + r.pass = (mismatch == SIZE_MAX); + r.bitsChecked = static_cast(kBits); + r.firstBadBit = (mismatch == SIZE_MAX) ? static_cast(kBits) + : static_cast(mismatch); + // got[] = the light holding the first mismatch (light 0 when clean). + const size_t badLight = (mismatch == SIZE_MAX) ? 0 : mismatch / bitsPerLight; + const size_t lightStart = badLight * bitsPerLight; + for (size_t b = 0; b < 24 && lightStart + b < cap.got; b++) { + const uint8_t bit = ((rxSymbols[lightStart + b] & 0x7FFF) + >= ((kT0H + kT1H) / 2)) ? 1 : 0; + r.got[b / 8] = static_cast((r.got[b / 8] << 1) | bit); + } + } + heap_caps_free(txSymbols); + heap_caps_free(rxSymbols); + return r; +} + +} // namespace mm::platform diff --git a/src/platform/platform.h b/src/platform/platform.h index 8f0e2568..46b27868 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -23,6 +23,9 @@ void free(void* ptr); void yield(); void delayMs(uint32_t ms); // blocking sleep; only use outside the hot path +void delayUs(uint32_t us); // blocking busy-wait for sub-ms protocol gaps (e.g. + // the WS2812 inter-frame latch); fine for a few + // hundred µs, not a general-purpose sleep size_t freeHeap(); // total free (internal + PSRAM if present) size_t freeInternalHeap(); // internal RAM only (for stack/HTTP/WiFi reserve check) size_t maxAllocBlock(); // largest contiguous block (any memory type — incl PSRAM) @@ -48,6 +51,15 @@ void getMacAddress(uint8_t mac[6]); const char* chipModel(); const char* sdkVersion(); +// WiFi co-processor status, for boards whose radio lives on a separate chip (the +// ESP32-P4 + on-board ESP32-C6 over esp_hosted). Returns a short status string: +// the detected co-processor firmware version when the link is up (e.g. +// "C6 fw 2.12.9"), "not detected" when the slave never completed its handshake or +// reports 0.0.0 (the tell for absent / incompatible C6 slave firmware), or "" on +// targets with a native radio (no co-processor). Lets SystemModule prove the C6 +// firmware state instead of guessing. Empty string => render nothing. +const char* coprocessorWifi(); + // This host's LAN IPv4 address as a dotted string, or "" if unavailable. // Desktop: the outbound interface address. ESP32: empty — the device IP is // owned by NetworkModule (WiFi/Ethernet), not the platform layer. @@ -172,13 +184,22 @@ struct ImprovDeviceInfo { // boardReady's release-store. Pass nullptr/0/nullptr to opt out (desktop // stub, future targets without BoardModule). Mirrors the ssid/password // triple: validate + buffer-write + flag-signal, scheduler thread reads. +// SET_TX_POWER RPC (command 0xFD) — when set, the Improv task validates the +// 1-byte dBm payload (0..21), writes it to txPowerOut, and publishes via +// txPowerReady's release-store. This is the pre-association escape hatch for +// boards whose LDO browns out at full TX power (LOLIN S3/S2): their +// boards.json cap normally arrives over HTTP *after* the device is online, +// which such a board can never reach — proven on the bench 2026-06-10. Same +// validate + buffer-write + flag-signal shape as SET_BOARD. bool improvProvisioningInit(const ImprovDeviceInfo& info, char* ssidOut, size_t ssidOutLen, char* passwordOut, size_t passwordOutLen, std::atomic* ready, char* statusBuf, size_t statusBufLen, char* boardOut = nullptr, size_t boardOutLen = 0, - std::atomic* boardReady = nullptr); + std::atomic* boardReady = nullptr, + uint8_t* txPowerOut = nullptr, + std::atomic* txPowerReady = nullptr); class UdpSocket { public: @@ -193,6 +214,20 @@ class UdpSocket { // parse + route lookup. Returns false on a bad IP. bool connect(const char* ip, uint16_t port); bool sendTo(const uint8_t* data, size_t len); // uses the connect()ed destination + // Receiver side (ArtNet in): listen on `port` on any interface + // (SO_REUSEADDR) and flip the socket non-blocking — note that flips the + // whole socket, sendTo() included. Returns false when the port is taken. + bool bind(uint16_t port); + // Non-blocking receive of one datagram: >0 = bytes copied into buf, -1 = + // nothing pending. Mirrors TcpConnection::read's contract minus the + // peer-closed 0 case (UDP has no connection to close). A datagram longer + // than maxLen is truncated. Pass `srcIp` to also get the sender's IPv4 + // octets (ArtNet discovery replies go back to the poller's address). + int recvFrom(uint8_t* buf, size_t maxLen, uint8_t srcIp[4] = nullptr); + // One-shot send to an explicit address — for replying on a bound, + // unconnected receive socket (e.g. ArtPollReply to the poller). connect()ed + // send sockets keep using sendTo(). + bool sendToAddr(const uint8_t ip[4], uint16_t port, const uint8_t* data, size_t len); void close(); private: @@ -261,4 +296,207 @@ class TcpServer { // Does not return. [[noreturn]] void reboot(); +// --------------------------------------------------------------------------- +// RMT WS2812 LED output (classic ESP32 + S3 + P4). The driver (src/light/drivers/ +// RmtLedDriver.h) does the symbol encode in domain code and may run several +// channels at once (one per pin); the platform owns only the peripheral. All +// no-ops on targets without RMT, so the driver compiles everywhere behind +// `if constexpr (platform::rmtTxChannels > 0)` (see platform_config.h) and is +// simply inert off the chips that have RMT. +// --------------------------------------------------------------------------- + +// Opaque handle to one configured RMT TX channel. `impl` is set by the platform +// (a heap struct holding the channel + encoder); the driver never inspects it. +struct RmtWs2812Handle { void* impl = nullptr; }; + +// Allocate + configure one RMT TX channel on `gpio`. `resolutionHz` is the tick +// clock the caller expresses symbol durations in; `invert` flips output polarity +// for inverting level-shifters. Returns false on failure (and on non-ESP32). +bool rmtWs2812Init(RmtWs2812Handle& h, uint8_t gpio, uint32_t resolutionHz, bool invert); + +// The tick resolution the platform actually granted (may differ from requested). +// The driver converts its ns timings to ticks with this. 0 if not initialised. +uint32_t rmtWs2812Resolution(const RmtWs2812Handle& h); + +// Start transmitting `symbolCount` pre-encoded WS2812 RMT symbols and return +// immediately — channels started back-to-back clock out concurrently. Pair with +// rmtWs2812Wait; the caller owns the inter-frame latch (delayUs) after the last +// wait. The symbol buffer must stay valid until the wait returns. Returns false +// when the channel isn't initialised (and on targets without RMT). +bool rmtWs2812Transmit(RmtWs2812Handle& h, const uint32_t* symbols, size_t symbolCount); + +// Block until the channel's in-flight transmission finishes, bounded by +// `timeoutMs` so a wedged peripheral can't hang the render tick forever — a +// timed-out frame is simply dropped and re-encoded next tick (self-heals). With +// N channels waited sequentially the worst case is N×timeoutMs; acceptable for +// the same self-healing reason. +void rmtWs2812Wait(RmtWs2812Handle& h, uint32_t timeoutMs); + +void rmtWs2812Deinit(RmtWs2812Handle& h); + +// RX loopback capture, on-device test only (no-op stub off ESP32). Capture up to +// `maxSymbols` pulse-duration symbols on `gpio` (jumpered from the TX pin) within +// `timeoutMs`. Returns the number captured. Used only by the loopback self-test. +size_t rmtWs2812RxCapture(uint8_t gpio, uint32_t resolutionHz, + uint32_t* outSymbols, size_t maxSymbols, uint32_t timeoutMs); + +// Self-contained RMT loopback self-test, runnable from the running firmware (the +// RmtLedDriver's loopbackTest control). Drives a known WS2812 pattern out `txGpio` +// and captures it back on `rxGpio` (the user jumpers them), proving the GPIO emits +// correct bytes on real silicon. All hardware (RMT TX/RX, the GPIO continuity +// pre-check) lives here so src/light/ stays platform-free. No-op returning a +// "not supported" result off ESP32. +struct RmtLoopbackResult { + bool jumperDetected = false; // plain-GPIO continuity pre-check (tx high→rx high, low→low) + bool pass = false; // captured bytes == sent bytes (or whole frame bit-exact) + uint8_t sent[3] = {}; // the per-light test pattern transmitted + uint8_t got[3] = {}; // the light holding the first mismatch (light 0 when clean) + uint32_t bitsChecked = 0; // total WS2812 bits verified (frame mode); 24 for the short test + uint32_t firstBadBit = 0; // index of the first wrong bit, or bitsChecked when all pass +}; +RmtLoopbackResult rmtWs2812Loopback(uint8_t txGpio, uint8_t rxGpio); + +// Whole-FRAME RMT loopback: instead of a 24-bit synthetic burst, transmit a +// real `lights`-light WS2812 frame (the per-light pattern 0xA5/0x00/0xFF +// repeated, `channels` per light) back to back like the render loop, capture +// the WHOLE frame on rxGpio and bit-verify every WS2812 bit. This is what +// catches frame-rate / sustained-transfer corruption and RF interference on +// the data line that a 24-bit burst can't — a single flipped bit anywhere in +// the frame fails the test and reports its position. No-op off ESP32. +RmtLoopbackResult rmtWs2812LoopbackFrame(uint8_t txGpio, uint8_t rxGpio, + uint16_t lights, uint8_t channels); + +// --------------------------------------------------------------------------- +// LCD_CAM parallel WS2812 output (ESP32-S3). The driver +// (src/light/drivers/LcdLedDriver.h) pre-encodes the WHOLE frame into one +// DMA buffer (3-slot encode in LcdSlots.h, domain code); the platform owns +// only the i80 bus/peripheral AND the DMA buffer itself — the buffer must be +// DMA-capable internal RAM (platform::alloc prefers PSRAM, which the +// peripheral can't stream from at full rate), so the platform allocates it at +// init and exposes the pointer for the driver's zero-copy encode. All inert +// on targets without the i80 LCD peripheral, guarded by +// `if constexpr (platform::lcdLanes == 0)` in the driver. +// --------------------------------------------------------------------------- + +// Opaque handle to one configured i80 bus + IO device + DMA frame buffer. +struct LcdWs2812Handle { void* impl = nullptr; }; + +// Create the 8-lane bus on `dataPins[0..laneCount)` plus the two peripheral- +// mandated lines WS2812 strands ignore: `wrGpio` (the pixel clock) and +// `dcGpio` (data/command). Allocates a zeroed DMA-capable frame buffer of +// `bufferBytes`. Returns false on any failure (bad pins, DMA memory pressure). +bool lcdWs2812Init(LcdWs2812Handle& h, const uint16_t* dataPins, uint8_t laneCount, + uint16_t wrGpio, uint16_t dcGpio, size_t bufferBytes); + +// The DMA frame buffer the driver encodes into (zero-copy), and its capacity +// — the driver's grow-only check. nullptr / 0 when not initialised. +uint8_t* lcdWs2812Buffer(const LcdWs2812Handle& h); +size_t lcdWs2812BufferCapacity(const LcdWs2812Handle& h); + +// Start the autonomous DMA transfer of the buffer's first `bytes` and return; +// pair with lcdWs2812Wait. Once started no CPU work remains — there is no +// refill deadline for WiFi to miss (the design difference vs the ISR-refilled +// rings in the hpwit/FastLED lineage). +bool lcdWs2812Transmit(LcdWs2812Handle& h, size_t bytes); + +// Block until the in-flight transfer finishes, bounded by `timeoutMs`; a +// timed-out frame is dropped and re-encoded next tick (self-heals, same +// stance as rmtWs2812Wait). +void lcdWs2812Wait(LcdWs2812Handle& h, uint32_t timeoutMs); + +void lcdWs2812Deinit(LcdWs2812Handle& h); + +// LCD loopback self-test: build a private FULL-WIDTH bus on the driver's +// real pins (the i80 peripheral configures all 8 data lines — a partial bus +// is rejected by the hardware layer) and transmit the caller's REAL encoded +// frame (`frame`/`frameBytes`, lane 0 = dataPins[0]) back to back, exactly +// like the render loop, while an RMT RX channel captures the whole frame off +// `rxGpio` and verifies every bit (RMT receive is transmitter-agnostic — the +// increment-1 rig reused). `dataBytes` is the slot-carrying prefix of the +// frame (before the latch pad); `rowBits` the bits per light row, so the +// expected pattern repeats per row. Testing the genuine frame matters: a +// short synthetic burst misses exactly the real-transfer failures (DMA +// descriptor boundaries, sustained-rate stalls). Same result shape as the +// RMT test; got[] holds the first mismatching row. No-op off the S3. +RmtLoopbackResult lcdWs2812Loopback(const uint16_t* dataPins, uint8_t laneCount, + uint16_t wrGpio, uint16_t dcGpio, uint16_t rxGpio, + const uint8_t* frame, size_t frameBytes, + size_t dataBytes, uint8_t rowBits); + +// --------------------------------------------------------------------------- +// Parlio (Parallel IO) WS2812 output — the ESP32-P4's parallel LED path, a +// sibling of the LCD_CAM i80 functions above. Same autonomous-whole-frame DMA +// shape, but Parlio is simpler: it takes the data GPIOs directly (no +// sacrificial WR/DC lines — Parlio generates the pixel clock itself from +// `pclkHz`) and allows ANY lane count (1..8 here), so there is no all-8-pins +// rule. The same encoder feeds it (LcdSlots.h — one bus word per slot, bit L = +// data line L). All inert on targets without Parlio, guarded by +// `if constexpr (platform::parlioLanes == 0)` in the driver. +// --------------------------------------------------------------------------- + +// Opaque handle to one configured Parlio TX unit + DMA frame buffer. +struct ParlioWs2812Handle { void* impl = nullptr; }; + +// Create a Parlio TX unit on `dataPins[0..laneCount)` clocked at `pclkHz` (the +// WS2812 slot rate), with a zeroed DMA-capable frame buffer of `bufferBytes`. +// No WR/DC pins — Parlio drives the clock internally. Returns false on failure. +bool parlioWs2812Init(ParlioWs2812Handle& h, const uint16_t* dataPins, + uint8_t laneCount, uint32_t pclkHz, size_t bufferBytes); + +// The DMA frame buffer the driver encodes into (zero-copy) + its capacity. +uint8_t* parlioWs2812Buffer(const ParlioWs2812Handle& h); +size_t parlioWs2812BufferCapacity(const ParlioWs2812Handle& h); + +// Start the autonomous DMA transfer of the buffer's first `bytes`; pair with +// parlioWs2812Wait. No refill deadline once started (single-shot, not the +// loop-transmission mode Parlio also offers). +bool parlioWs2812Transmit(ParlioWs2812Handle& h, size_t bytes); + +// Block until the in-flight transfer finishes, bounded by `timeoutMs`; a +// timed-out frame is dropped and re-encoded next tick (self-heals). +void parlioWs2812Wait(ParlioWs2812Handle& h, uint32_t timeoutMs); + +void parlioWs2812Deinit(ParlioWs2812Handle& h); + +// Parlio loopback self-test — same contract + result shape as the LCD/RMT +// loopbacks: a private Parlio TX unit transmits the caller's real frame back to +// back while rmtWs2812RxCapture reads it off `rxGpio` (lane 0 carries the +// pattern) and every bit is verified. `dataBytes`/`rowBits` as in lcdWs2812Loopback. +RmtLoopbackResult parlioWs2812Loopback(const uint16_t* dataPins, uint8_t laneCount, + uint16_t rxGpio, const uint8_t* frame, + size_t frameBytes, size_t dataBytes, + uint8_t rowBits); + +// --------------------------------------------------------------------------- +// I2S audio input (digital MEMS microphone, e.g. INMP441). Two seams only: +// the I2S read (audioMic*) and the FFT kernel (audioFft). Everything else — +// DC strip, RMS, windowing, the magnitude->16-band log mapping, noise-floor/gain — +// is host-tested domain code (src/core/AudioLevel.h, AudioBands.h), so the level +// and band math runs in CI without hardware. On desktop audioMicRead returns 0 +// (no capture) but audioFft is a real (naive) DFT, so the whole +// read->window->FFT->bands path is still exercised host-side. +// All inert on targets without I2S, guarded by `if constexpr (platform::hasI2sMic)`. +// --------------------------------------------------------------------------- + +// Opaque handle to one configured I2S RX channel (standard/Philips mode). +struct AudioMicHandle { void* impl = nullptr; }; + +// Bring up an I2S RX channel reading the mic on the given pins at `sampleRate` +// (24-bit data in a 32-bit slot, mono). Returns false on failure (bad pins, +// no I2S, out of memory) — the module then idles with a status error. +bool audioMicInit(AudioMicHandle& h, uint16_t wsPin, uint16_t sdPin, + uint16_t sckPin, uint32_t sampleRate); + +// Read up to `maxSamples` 32-bit samples into `out`; returns the count read +// (0 if none ready / not initialised). Non-blocking enough for the render tick. +size_t audioMicRead(AudioMicHandle& h, int32_t* out, size_t maxSamples); + +void audioMicDeinit(AudioMicHandle& h); + +// Real-input FFT kernel: `windowed` holds `n` (a power of two) windowed samples; +// fills `outMag` with the n/2 magnitude bins. esp-dsp's float `dsps_fft2r_fc32` +// on ESP32 (the FPU makes float faster than fixed-point); a naive O(n^2) DFT on +// desktop — correct, only fast enough for the host tests' small n. +void audioFft(const float* windowed, size_t n, float* outMag); + } // namespace mm::platform diff --git a/src/ui/app.js b/src/ui/app.js index 4cd9c234..6016d1a9 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -974,17 +974,58 @@ function createControl(moduleName, moduleType, ctrl) { break; } case "uint16": { - const input = document.createElement("input"); - input.type = "number"; - input.value = ctrl.value ?? 0; - input.dataset.mid = moduleName; - input.dataset.key = ctrl.name; - input.addEventListener("input", () => { - dragTs[key] = Date.now(); - debounceSend(key, 500, () => sendControl(moduleName, ctrl.name, parseInt(input.value))); - }); - row.appendChild(input); - appendResetButton(row, moduleName, ctrl, def, () => { input.value = def; }); + // Bounded (server sent an explicit max below the type ceiling) → + // slider, like uint8/int16. Unbounded (max == 65535, the default for + // port/universe-style values with no natural range) → plain number. + const uMin = Number(ctrl.min ?? 0); + const uMax = Number(ctrl.max ?? 65535); + if (uMax < 65535) { + const input = document.createElement("input"); + input.type = "range"; + input.min = uMin; + input.max = uMax; + input.value = Math.max(uMin, Math.min(uMax, Number(ctrl.value ?? 0))); + input.dataset.mid = moduleName; + input.dataset.key = ctrl.name; + const numInput = document.createElement("input"); + numInput.type = "number"; + numInput.className = "control-value-input"; + numInput.min = uMin; + numInput.max = uMax; + numInput.value = input.value; + input.addEventListener("input", () => { + dragTs[key] = Date.now(); + numInput.value = input.value; + debounceSend(key, 150, () => sendControl(moduleName, ctrl.name, parseInt(input.value))); + }); + numInput.addEventListener("input", () => { + const v = Math.max(uMin, Math.min(uMax, parseInt(numInput.value) || 0)); + input.value = v; + debounceSend(key, 150, () => sendControl(moduleName, ctrl.name, v)); + }); + row.appendChild(input); + row.appendChild(numInput); + appendResetButton(row, moduleName, ctrl, def, () => { + input.value = def; numInput.value = def; + }); + } else { + const input = document.createElement("input"); + input.type = "number"; + input.value = ctrl.value ?? 0; + input.dataset.mid = moduleName; + input.dataset.key = ctrl.name; + input.addEventListener("input", () => { + dragTs[key] = Date.now(); + // Sanitise: empty/garbage → 0, clamp into the uint16 range so a + // NaN or out-of-range value never reaches the device. + let v = parseInt(input.value, 10); + if (Number.isNaN(v)) v = 0; + v = Math.max(0, Math.min(65535, v)); + debounceSend(key, 500, () => sendControl(moduleName, ctrl.name, v)); + }); + row.appendChild(input); + appendResetButton(row, moduleName, ctrl, def, () => { input.value = def; }); + } break; } case "int16": { @@ -1371,8 +1412,74 @@ function allModules() { return out; } +// Reconcile a card's control rows when its set of VISIBLE controls changed (a +// `hidden` flag flipped at runtime, e.g. NetworkModule's static-IP fields or +// RmtLedDriver's loopbackRxPin). The value-patch path in updateModuleControls +// can't add or remove rows, so this handles that half. Returns true if it +// changed the DOM. No-op (returns false) on the common frame where nothing moved. +// +// Position-stable by design: it inserts each newly-visible row at its correct +// index among the existing control rows and removes rows that became hidden — +// it does NOT tear down and re-append every row (that would land them after the +// card's child-module block / install-picker mount, never converge, and re-fire +// every WS tick — a render loop that wedges the UI). +function syncVisibleControls(mod) { + const card = document.querySelector(`.card[data-module="${cssEscape(mod.name)}"]`); + if (!card) return false; + const host = card.querySelector(".card-controls-collapse") || card; + + const wantNames = mod.controls.filter(c => !c.hidden).map(c => c.name); + const haveRows = [...host.querySelectorAll(":scope > .control-row[data-key]")]; + const haveNames = haveRows.map(r => r.dataset.key); + if (wantNames.length === haveNames.length && wantNames.every((n, i) => n === haveNames[i])) { + return false; // unchanged — the common case + } + + // Remove rows whose control is no longer visible. + const wantSet = new Set(wantNames); + for (const row of haveRows) { + if (!wantSet.has(row.dataset.key)) row.remove(); + } + // Insert each visible control's row at its correct position. The anchor is the + // first existing control row that should come AFTER this one; null → append + // before the children block (insertBefore(node, null) appends to host's end, + // but control rows precede .card-children which lives on the card, not here). + const visibleControls = mod.controls.filter(c => !c.hidden); + for (let i = 0; i < visibleControls.length; i++) { + const name = visibleControls[i].name; + if (host.querySelector(`:scope > .control-row[data-key="${cssEscape(name)}"]`)) continue; + const row = createControl(mod.name, mod.type, visibleControls[i]); + if (!row) continue; + // Anchor: the rendered row of the next visible control that already exists. + let anchor = null; + for (let j = i + 1; j < visibleControls.length && !anchor; j++) { + anchor = host.querySelector(`:scope > .control-row[data-key="${cssEscape(visibleControls[j].name)}"]`); + } + // No later control row exists yet → keep this row above the card's + // children block / install-picker mount / footer (which live in the host + // when host===card), so controls never render below the children. + if (!anchor) { + anchor = host.querySelector(":scope > .card-children") + || host.querySelector(":scope > .install-picker-host") + || host.querySelector(":scope > .card-footer"); + } + host.insertBefore(row, anchor); + } + return true; +} + function updateModuleControls(mod) { if (!mod.controls) return; + + // Conditional controls: a module can flip a control's `hidden` flag at runtime + // (e.g. RmtLedDriver reveals loopbackRxPin while the test is on, NetworkModule + // reveals static-IP fields). The value-patch loop below only updates controls + // already in the DOM — it can't add or remove one. So first detect whether the + // set of VISIBLE controls drifted from what's rendered, and if so re-render + // this card's control rows. Cheap: only fires on the rare frame where a hidden + // flag actually changed. + if (syncVisibleControls(mod)) return; // re-rendered — values are fresh, skip patch + for (const ctrl of mod.controls) { const mid = cssEscape(mod.name); const k = cssEscape(ctrl.name); diff --git a/src/ui/install-picker.js b/src/ui/install-picker.js index 4c19bb4f..65d8c876 100644 --- a/src/ui/install-picker.js +++ b/src/ui/install-picker.js @@ -706,6 +706,22 @@ export const installPicker = { return _lastState ? (_lastState.selectedBoard || "") : ""; }, + /** + * The picked board's boards.json TX-power cap + * (controls.Network.txPowerSetting), or null when the board has none / + * no board is picked. The orchestrator pushes it over Improv BEFORE + * provisioning — brown-out-prone boards (LOLIN S3/S2) fail their first + * association at full power, so the cap can't wait for the post-online + * HTTP fan-out. + */ + getSelectedBoardTxPower() { + if (!_lastState || !_lastState.selectedBoard || !_lastState.boards) return null; + const entry = _lastState.boards.find(b => b.name === _lastState.selectedBoard); + const v = entry && entry.controls && entry.controls.Network + && entry.controls.Network.txPowerSetting; + return (typeof v === "number") ? v : null; + }, + /** * Chip family from the last successful Detect ("ESP32" / "ESP32-S3"), or "" * if Detect hasn't run / failed / isn't wired. Always "" on the on-device diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index f05e020b..23c011fa 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -21,8 +21,11 @@ add_executable(mm_tests unit/core/unit_SystemModule.cpp # light/ - unit/light/unit_ArtNetSendDriver_no_alloc_in_loop.cpp - unit/light/unit_ArtNetSendDriver_packet.cpp + unit/light/unit_AudioModule.cpp + unit/light/unit_AudioLevel.cpp + unit/light/unit_AudioBands.cpp + unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp + unit/light/unit_NetworkSendDriver_packet.cpp unit/light/unit_BlendMap.cpp unit/light/unit_CheckerboardEffect.cpp unit/light/unit_Correction.cpp @@ -47,6 +50,14 @@ add_executable(mm_tests unit/light/unit_PlasmaEffect.cpp unit/light/unit_PreviewDriver.cpp unit/light/unit_RainbowEffect.cpp + unit/light/unit_NetworkReceiveEffect.cpp + unit/light/unit_NetworkReceiveEffect_protocols.cpp + unit/light/unit_LcdLedDriver.cpp + unit/light/unit_LcdLedEncoder.cpp + unit/light/unit_ParlioLedDriver.cpp + unit/light/unit_RmtLedEncoder.cpp + unit/light/unit_RmtLedDriver_lifecycle.cpp + unit/light/unit_RmtLedDriver_pins.cpp ) target_include_directories(mm_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/test/scenario_runner.cpp b/test/scenario_runner.cpp index eb3b0d0a..31a568ec 100644 --- a/test/scenario_runner.cpp +++ b/test/scenario_runner.cpp @@ -22,14 +22,19 @@ #include "light/effects/GlowParticlesEffect.h" #include "light/effects/CheckerboardEffect.h" #include "light/effects/SpiralEffect.h" +#include "light/effects/RingsEffect.h" #include "light/effects/RipplesEffect.h" #include "light/effects/LavaLampEffect.h" #include "light/effects/GameOfLifeEffect.h" #include "light/modifiers/MultiplyModifier.h" #include "light/modifiers/CheckerboardModifier.h" #include "light/drivers/Drivers.h" -#include "light/drivers/ArtNetSendDriver.h" +#include "light/drivers/NetworkSendDriver.h" #include "light/drivers/PreviewDriver.h" +#include "core/SystemModule.h" +#include "core/AudioModule.h" +#include "light/effects/AudioVolumeEffect.h" +#include "light/effects/AudioSpectrumEffect.h" #include "platform/platform.h" #include @@ -180,14 +185,19 @@ static void registerScenarioTypes() { mm::ModuleFactory::registerType("GlowParticlesEffect"); mm::ModuleFactory::registerType("CheckerboardEffect"); mm::ModuleFactory::registerType("SpiralEffect"); + mm::ModuleFactory::registerType("RingsEffect"); mm::ModuleFactory::registerType("RipplesEffect"); mm::ModuleFactory::registerType("LavaLampEffect"); mm::ModuleFactory::registerType("GameOfLifeEffect"); mm::ModuleFactory::registerType("MultiplyModifier"); mm::ModuleFactory::registerType("CheckerboardModifier"); mm::ModuleFactory::registerType("Drivers"); - mm::ModuleFactory::registerType("ArtNetSendDriver"); + mm::ModuleFactory::registerType("NetworkSendDriver"); mm::ModuleFactory::registerType("PreviewDriver"); + mm::ModuleFactory::registerType("SystemModule"); + mm::ModuleFactory::registerType("AudioModule"); + mm::ModuleFactory::registerType("AudioVolumeEffect"); + mm::ModuleFactory::registerType("AudioSpectrumEffect"); done = true; } diff --git a/test/scenarios/core/scenario_MoonModule_control_change.json b/test/scenarios/core/scenario_MoonModule_control_change.json index 9eae600f..55f72e33 100644 --- a/test/scenarios/core/scenario_MoonModule_control_change.json +++ b/test/scenarios/core/scenario_MoonModule_control_change.json @@ -62,7 +62,7 @@ "name": "fix-artnet", "op": "add_module", "id": "ArtNet", - "type": "ArtNetSendDriver", + "type": "NetworkSendDriver", "parent_id": "Drivers" } ], @@ -364,7 +364,7 @@ "observed": { "pc-macos": { "tick_us": [ - 111, + 110, 522 ], "free_heap": [ @@ -377,7 +377,7 @@ ], "at": [ "2026-06-02", - "2026-06-08" + "2026-06-10" ] }, "esp32-eth-wifi": { diff --git a/test/scenarios/light/scenario_AllEffects_grid_sizes.json b/test/scenarios/light/scenario_AllEffects_grid_sizes.json index 08e3bfb1..55957833 100644 --- a/test/scenarios/light/scenario_AllEffects_grid_sizes.json +++ b/test/scenarios/light/scenario_AllEffects_grid_sizes.json @@ -6,7 +6,7 @@ "Layouts", "GridLayout", "Drivers", - "ArtNetSendDriver", + "NetworkSendDriver", "PreviewDriver", "LinesEffect", "RainbowEffect", @@ -19,11 +19,11 @@ "GlowParticlesEffect", "CheckerboardEffect", "SpiralEffect", - "RipplesEffect", + "RingsEffect", "LavaLampEffect", "GameOfLifeEffect" ], - "description": "Sweep every effect (no modifier) across 16/32/64/128 square grids and measure tick/FPS, free internal heap, max internal block per (effect, size). The scenario prepares its own canvas: clear_children wipes whatever layouts/layers/drivers the device had, then it rebuilds exactly one Layout(Grid) + one Layer + one effect (no modifier) + ArtNet, so the measurement is each effect's raw cost over the full grid through the real output driver, on any starting device state. PreviewDriver is apparatus (non-deletable) so it survives the clear. Effects are swapped via replace_module at a fixed Layer child slot; grid resized via set_control (width then height, measuring after height so we never measure an N x 128 stripe).", + "description": "Sweep the effect set (no modifier) across 16/32/64/128 square grids and measure tick/FPS, free internal heap, max internal block per (effect, size). Covers 14 effects: Lines, Rainbow, Noise, Plasma, PlasmaPalette, Metaballs, Fire, Particles, GlowParticles, Checkerboard, Spiral, Rings, LavaLamp, GameOfLife. NOT yet covered: RipplesEffect (added later; its measurement block is a pending addition — see backlog). The scenario prepares its own canvas: clear_children wipes whatever layouts/layers/drivers the device had, then it rebuilds exactly one Layout(Grid) + one Layer + one effect (no modifier) + NetworkSendDriver, so the measurement is each effect's raw cost over the full grid through the real output driver, on any starting device state. PreviewDriver is apparatus (non-deletable) so it survives the clear. Effects are swapped via replace_module at a fixed Layer child slot; grid resized via set_control (width then height, measuring after height so we never measure an N x 128 stripe).", "fixture": [ { "name": "fix-layouts", @@ -81,7 +81,7 @@ "name": "fix-artnet", "op": "add_module", "id": "ArtNet", - "type": "ArtNetSendDriver", + "type": "NetworkSendDriver", "parent_id": "Drivers" }, { @@ -156,7 +156,7 @@ "name": "build-artnet", "op": "add_module", "id": "ArtNet", - "type": "ArtNetSendDriver", + "type": "NetworkSendDriver", "parent_id": "Drivers" }, { @@ -506,7 +506,7 @@ "pc-macos": { "tick_us": [ 0, - 0 + 1 ], "free_heap": [ 0, @@ -518,7 +518,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-10" ] }, "esp32-eth": { @@ -650,7 +650,7 @@ "pc-macos": { "tick_us": [ 8, - 9 + 14 ], "free_heap": [ 0, @@ -662,7 +662,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-12" ] }, "esp32-eth": { @@ -722,7 +722,7 @@ "pc-macos": { "tick_us": [ 35, - 39 + 41 ], "free_heap": [ 0, @@ -734,7 +734,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-12" ] }, "esp32-eth": { @@ -886,7 +886,7 @@ "pc-macos": { "tick_us": [ 14, - 16 + 17 ], "free_heap": [ 0, @@ -898,7 +898,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-10" ] }, "esp32-eth": { @@ -958,7 +958,7 @@ "pc-macos": { "tick_us": [ 64, - 74 + 80 ], "free_heap": [ 0, @@ -970,7 +970,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-11" ] }, "esp32-eth": { @@ -1030,7 +1030,7 @@ "pc-macos": { "tick_us": [ 306, - 342 + 737 ], "free_heap": [ 0, @@ -1042,7 +1042,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-12" ] }, "esp32-eth": { @@ -1194,7 +1194,7 @@ "pc-macos": { "tick_us": [ 3, - 3 + 5 ], "free_heap": [ 0, @@ -1206,7 +1206,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-12" ] }, "esp32-eth": { @@ -1266,7 +1266,7 @@ "pc-macos": { "tick_us": [ 11, - 15 + 16 ], "free_heap": [ 0, @@ -1278,7 +1278,7 @@ ], "at": [ "2026-06-07", - "2026-06-08" + "2026-06-12" ] }, "esp32-eth": { @@ -1338,7 +1338,7 @@ "pc-macos": { "tick_us": [ 44, - 52 + 58 ], "free_heap": [ 0, @@ -1350,7 +1350,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-10" ] }, "esp32-eth": { @@ -1502,7 +1502,7 @@ "pc-macos": { "tick_us": [ 1, - 1 + 2 ], "free_heap": [ 0, @@ -1514,7 +1514,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-10" ] }, "esp32-eth": { @@ -1646,7 +1646,7 @@ "pc-macos": { "tick_us": [ 20, - 24 + 31 ], "free_heap": [ 0, @@ -1658,7 +1658,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-12" ] }, "esp32-eth": { @@ -1738,7 +1738,7 @@ "pc-macos": { "tick_us": [ 1, - 1 + 2 ], "free_heap": [ 0, @@ -1750,7 +1750,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-12" ] }, "esp32-eth": { @@ -1882,7 +1882,7 @@ "pc-macos": { "tick_us": [ 16, - 18 + 26 ], "free_heap": [ 0, @@ -1894,7 +1894,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-12" ] }, "esp32-eth": { @@ -1954,7 +1954,7 @@ "pc-macos": { "tick_us": [ 63, - 76 + 190 ], "free_heap": [ 0, @@ -1966,7 +1966,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-12" ] }, "esp32-eth": { @@ -2118,7 +2118,7 @@ "pc-macos": { "tick_us": [ 3, - 4 + 7 ], "free_heap": [ 0, @@ -2130,7 +2130,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-12" ] }, "esp32-eth": { @@ -2190,7 +2190,7 @@ "pc-macos": { "tick_us": [ 13, - 18 + 36 ], "free_heap": [ 0, @@ -2202,7 +2202,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-12" ] }, "esp32-eth": { @@ -2262,7 +2262,7 @@ "pc-macos": { "tick_us": [ 52, - 59 + 147 ], "free_heap": [ 0, @@ -2274,7 +2274,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-12" ] }, "esp32-eth": { @@ -2877,7 +2877,7 @@ "observed": { "pc-macos": { "tick_us": [ - 70, + 69, 129 ], "free_heap": [ @@ -2890,7 +2890,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-10" ] }, "esp32-eth": { @@ -3350,7 +3350,7 @@ "pc-macos": { "tick_us": [ 4, - 5 + 6 ], "free_heap": [ 0, @@ -3362,7 +3362,7 @@ ], "at": [ "2026-06-07", - "2026-06-08" + "2026-06-11" ] }, "esp32-eth": { @@ -3548,45 +3548,45 @@ } }, { - "name": "RipplesEffect-pre-w", + "name": "RingsEffect-pre-w", "op": "set_control", "id": "Grid", "key": "width", "value": 16 }, { - "name": "RipplesEffect-pre-h", + "name": "RingsEffect-pre-h", "op": "set_control", "id": "Grid", "key": "height", "value": 16 }, { - "name": "fx-RipplesEffect", + "name": "fx-RingsEffect", "op": "replace_module", "id": "FX", - "type": "RipplesEffect" + "type": "RingsEffect" }, { - "name": "RipplesEffect-16x16-w", + "name": "RingsEffect-16x16-w", "op": "set_control", "id": "Grid", "key": "width", "value": 16 }, { - "name": "RipplesEffect-16x16", + "name": "RingsEffect-16x16", "op": "set_control", "id": "Grid", "key": "height", "value": 16, "measure": true, - "description": "RipplesEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block.", + "description": "RingsEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block.", "observed": { "pc-macos": { "tick_us": [ - 3, - 3 + 1, + 4 ], "free_heap": [ 0, @@ -3598,7 +3598,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-11" ] }, "esp32-eth": { @@ -3640,24 +3640,24 @@ } }, { - "name": "RipplesEffect-32x32-w", + "name": "RingsEffect-32x32-w", "op": "set_control", "id": "Grid", "key": "width", "value": 32 }, { - "name": "RipplesEffect-32x32", + "name": "RingsEffect-32x32", "op": "set_control", "id": "Grid", "key": "height", "value": 32, "measure": true, - "description": "RipplesEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block.", + "description": "RingsEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block.", "observed": { "pc-macos": { "tick_us": [ - 8, + 4, 12 ], "free_heap": [ @@ -3670,7 +3670,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-10" ] }, "esp32-eth": { @@ -3712,24 +3712,24 @@ } }, { - "name": "RipplesEffect-64x64-w", + "name": "RingsEffect-64x64-w", "op": "set_control", "id": "Grid", "key": "width", "value": 64 }, { - "name": "RipplesEffect-64x64", + "name": "RingsEffect-64x64", "op": "set_control", "id": "Grid", "key": "height", "value": 64, "measure": true, - "description": "RipplesEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block.", + "description": "RingsEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block.", "observed": { "pc-macos": { "tick_us": [ - 28, + 17, 33 ], "free_heap": [ @@ -3742,7 +3742,7 @@ ], "at": [ "2026-06-07", - "2026-06-08" + "2026-06-10" ] }, "esp32-eth": { @@ -3784,25 +3784,25 @@ } }, { - "name": "RipplesEffect-128x128-w", + "name": "RingsEffect-128x128-w", "op": "set_control", "id": "Grid", "key": "width", "value": 128 }, { - "name": "RipplesEffect-128x128", + "name": "RingsEffect-128x128", "op": "set_control", "id": "Grid", "key": "height", "value": 128, "measure": true, - "description": "RipplesEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block.", + "description": "RingsEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block.", "observed": { "pc-macos": { "tick_us": [ - 108, - 119 + 72, + 126 ], "free_heap": [ 0, @@ -3814,7 +3814,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-12" ] }, "esp32-eth": { @@ -4109,8 +4109,8 @@ "observed": { "pc-macos": { "tick_us": [ - 31, - 35 + 30, + 45 ], "free_heap": [ 0, @@ -4122,7 +4122,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-11" ] }, "esp32-eth": { @@ -4274,7 +4274,7 @@ "pc-macos": { "tick_us": [ 1, - 1 + 3 ], "free_heap": [ 0, @@ -4286,7 +4286,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-11" ] }, "esp32-eth": { @@ -4346,7 +4346,7 @@ "pc-macos": { "tick_us": [ 5, - 6 + 7 ], "free_heap": [ 0, @@ -4358,7 +4358,7 @@ ], "at": [ "2026-06-07", - "2026-06-08" + "2026-06-11" ] }, "esp32-eth": { @@ -4418,7 +4418,7 @@ "pc-macos": { "tick_us": [ 35, - 51 + 201 ], "free_heap": [ 0, @@ -4430,7 +4430,7 @@ ], "at": [ "2026-06-07", - "2026-06-08" + "2026-06-11" ] }, "esp32-eth": { diff --git a/test/scenarios/light/scenario_Audio_mutation.json b/test/scenarios/light/scenario_Audio_mutation.json new file mode 100644 index 00000000..3fc409a3 --- /dev/null +++ b/test/scenarios/light/scenario_Audio_mutation.json @@ -0,0 +1,269 @@ +{ + "name": "scenario_Audio_mutation", + "module": "AudioModule", + "mode": "mutate", + "also": [ + "SystemModule", + "Layouts", + "GridLayout", + "Layer", + "RainbowEffect", + "AudioVolumeEffect", + "AudioSpectrumEffect", + "Drivers", + "PreviewDriver" + ], + "description": "Add / configure / remove the AudioModule peripheral and an audio-reactive effect while the render pipeline runs, proving the robustness rule for the audio producer/consumer pair. AudioModule is a Peripheral (it sits beside the pipeline, publishing an AudioFrame), and the audio effects read it through the static AudioModule::latestFrame() accessor, NOT a boot-time pointer — so add/remove can happen in any order at runtime. The checks assert the pipeline keeps RENDERING (buffer non-null, fps measurable) through each mutation: adding the mic, setting its pins (the user-configures-then-runs flow), adding a consumer effect, and crucially REMOVING the mic while a consumer is still live (the consumer must fall back to a silent frame, never deref a dangling pointer — the bug the boot-loop fix and the unit lifecycle tests pin, here proven end-to-end through the Scheduler). On the host the mic is inert (hasI2sMic false), so this exercises the wiring/lifecycle, not real capture; capture is proven on hardware. Grid is 64x64 so the tick stays above the host microsecond clock at every step.", + "fixture": [ + { + "name": "fix-system", + "description": "SystemModule: the top-level container peripherals (mic, board) hang under, mirroring main.cpp where the user adds AudioModule beside BoardModule.", + "op": "add_module", + "id": "System", + "type": "SystemModule" + }, + { + "name": "fix-layouts", + "op": "add_module", + "id": "Layouts", + "type": "Layouts" + }, + { + "name": "fix-grid", + "op": "add_module", + "id": "Grid", + "type": "GridLayout", + "parent_id": "Layouts", + "props": { + "width": 64, + "height": 64 + } + }, + { + "name": "fix-layer", + "op": "add_module", + "id": "Layer", + "type": "Layer", + "props": { + "layouts": "Layouts", + "channelsPerLight": 3 + } + }, + { + "name": "fix-rainbow", + "op": "add_module", + "id": "Rainbow", + "type": "RainbowEffect", + "parent_id": "Layer" + }, + { + "name": "fix-drivers", + "op": "add_module", + "id": "Drivers", + "type": "Drivers", + "props": { + "layer": "Layer" + } + }, + { + "name": "fix-preview", + "op": "add_module", + "id": "Preview", + "type": "PreviewDriver", + "parent_id": "Drivers" + } + ], + "steps": [ + { + "name": "measure-pipeline-only", + "description": "Baseline: the render pipeline runs with no audio module present.", + "op": "measure", + "measure": true, + "bounds": { + "fps": { + "min": 1 + } + }, + "observed": { + "pc-macos": { + "tick_us": [ + 9, + 10 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-12", + "2026-06-12" + ] + } + } + }, + { + "name": "add-audio-module", + "description": "Add the AudioModule peripheral under SystemModule (where the user adds it, beside the board). Pins default unset, so it stays idle; the pipeline must keep rendering.", + "op": "add_module", + "id": "Audio", + "type": "AudioModule", + "parent_id": "System" + }, + { + "name": "measure-audio-added", + "description": "Pipeline still renders with the (idle, unconfigured) mic added.", + "op": "measure", + "measure": true, + "bounds": { + "fps": { + "min": 1 + } + }, + "observed": { + "pc-macos": { + "tick_us": [ + 9, + 9 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-12", + "2026-06-12" + ] + } + } + }, + { + "name": "configure-audio-pins", + "description": "Set a mic pin — the user-configures-then-runs flow. On host the mic is inert, but the buildState rebuild must not disturb the running pipeline.", + "op": "set_control", + "id": "Audio", + "key": "sdPin", + "value": 5 + }, + { + "name": "add-audio-consumer", + "description": "Add an AudioVolumeEffect consumer under the Layer. It reads the mic via the static accessor; with the mic present it gets the live (silent, on host) frame.", + "op": "add_module", + "id": "Vol", + "type": "AudioVolumeEffect", + "parent_id": "Layer" + }, + { + "name": "measure-consumer-live", + "description": "Pipeline renders with the producer + consumer both wired.", + "op": "measure", + "measure": true, + "bounds": { + "fps": { + "min": 1 + } + }, + "observed": { + "pc-macos": { + "tick_us": [ + 9, + 10 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-12", + "2026-06-12" + ] + } + } + }, + { + "name": "remove-audio-module", + "description": "Remove the mic while the consumer is STILL live. The consumer must fall back to AudioModule::latestFrame()'s static silence — no dangling pointer, no crash. This is the robustness rule's hardest case for this pair.", + "op": "remove_module", + "id": "Audio" + }, + { + "name": "measure-after-mic-removed", + "description": "Mic gone, consumer remains: pipeline keeps rendering on silent audio (buffer non-null, fps measurable). No crash from the orphaned consumer.", + "op": "measure", + "measure": true, + "bounds": { + "fps": { + "min": 1 + } + }, + "observed": { + "pc-macos": { + "tick_us": [ + 9, + 9 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-12", + "2026-06-12" + ] + } + } + }, + { + "name": "remove-audio-consumer", + "description": "Remove the orphaned consumer too — clean teardown, pipeline still live.", + "op": "remove_module", + "id": "Vol" + }, + { + "name": "measure-back-to-baseline", + "description": "Both audio modules gone: back to the pipeline-only baseline, still rendering.", + "op": "measure", + "measure": true, + "bounds": { + "fps": { + "min": 1 + } + }, + "observed": { + "pc-macos": { + "tick_us": [ + 9, + 10 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-12", + "2026-06-12" + ] + } + } + } + ] +} diff --git a/test/scenarios/light/scenario_GridLayout_grid_sizes.json b/test/scenarios/light/scenario_GridLayout_grid_sizes.json index 5854090e..68376805 100644 --- a/test/scenarios/light/scenario_GridLayout_grid_sizes.json +++ b/test/scenarios/light/scenario_GridLayout_grid_sizes.json @@ -7,7 +7,7 @@ "MultiplyModifier", "NoiseEffect", "Drivers", - "ArtNetSendDriver", + "NetworkSendDriver", "PreviewDriver" ], "description": "Walk the grid through 16x16 → 32x32 → 64x64 → 128x128 and assert a per-size FPS floor.", @@ -66,7 +66,7 @@ "name": "fix-artnet", "op": "add_module", "id": "ArtNet", - "type": "ArtNetSendDriver", + "type": "NetworkSendDriver", "parent_id": "Drivers" }, { @@ -309,7 +309,7 @@ "pc-macos": { "tick_us": [ 5, - 9 + 13 ], "free_heap": [ 0, @@ -321,7 +321,7 @@ ], "at": [ "2026-06-02", - "2026-06-06" + "2026-06-11" ] }, "esp32-eth-wifi": { @@ -463,7 +463,7 @@ "pc-macos": { "tick_us": [ 23, - 33 + 223 ], "free_heap": [ 0, @@ -475,7 +475,7 @@ ], "at": [ "2026-06-02", - "2026-06-05" + "2026-06-11" ] }, "esp32-eth-wifi": { @@ -617,7 +617,7 @@ "pc-macos": { "tick_us": [ 98, - 201 + 204 ], "free_heap": [ 0, @@ -629,7 +629,7 @@ ], "at": [ "2026-06-02", - "2026-06-05" + "2026-06-11" ] }, "esp32-eth-wifi": { diff --git a/test/scenarios/light/scenario_GridLayout_resize.json b/test/scenarios/light/scenario_GridLayout_resize.json index 45c8290d..c2480e74 100644 --- a/test/scenarios/light/scenario_GridLayout_resize.json +++ b/test/scenarios/light/scenario_GridLayout_resize.json @@ -62,7 +62,7 @@ "name": "fix-artnet", "op": "add_module", "id": "ArtNet", - "type": "ArtNetSendDriver", + "type": "NetworkSendDriver", "parent_id": "Drivers" } ], diff --git a/test/scenarios/light/scenario_Layer_base_pipeline.json b/test/scenarios/light/scenario_Layer_base_pipeline.json index 408fb77f..258696a0 100644 --- a/test/scenarios/light/scenario_Layer_base_pipeline.json +++ b/test/scenarios/light/scenario_Layer_base_pipeline.json @@ -6,9 +6,9 @@ "GridLayout", "RainbowEffect", "Drivers", - "ArtNetSendDriver" + "NetworkSendDriver" ], - "description": "Core pipeline: build Layouts→Grid→Layer→RainbowEffect→Drivers→ArtNetSendDriver from scratch and verify each module wires correctly. Drives the bounded FPS check at the end so a render-path regression is caught.", + "description": "Core pipeline: build Layouts→Grid→Layer→RainbowEffect→Drivers→NetworkSendDriver from scratch and verify each module wires correctly. Drives the bounded FPS check at the end so a render-path regression is caught.", "steps": [ { "name": "add-layout-group", @@ -60,10 +60,10 @@ }, { "name": "add-artnet", - "description": "Add ArtNetSendDriver and run the bounded FPS measurement (expected to stay at >=80% of the rated FPS for the 128x128 grid this scenario builds; min_pct needs a live baseline, so it gates only on hardware and is skipped with a WARN in the desktop runner).", + "description": "Add NetworkSendDriver and run the bounded FPS measurement (expected to stay at >=80% of the rated FPS for the 128x128 grid this scenario builds; min_pct needs a live baseline, so it gates only on hardware and is skipped with a WARN in the desktop runner).", "op": "add_module", "id": "ArtNet", - "type": "ArtNetSendDriver", + "type": "NetworkSendDriver", "parent_id": "Drivers", "measure": true, "bounds": { diff --git a/test/scenarios/light/scenario_Layer_buildup.json b/test/scenarios/light/scenario_Layer_buildup.json index 42451564..ee8d360d 100644 --- a/test/scenarios/light/scenario_Layer_buildup.json +++ b/test/scenarios/light/scenario_Layer_buildup.json @@ -8,7 +8,7 @@ "RainbowEffect", "MultiplyModifier", "Drivers", - "ArtNetSendDriver" + "NetworkSendDriver" ], "description": "Start empty, add modules step by step, measure tick + heap after each meaningful pipeline state. Surfaces 'how much does each module cost?' so a regression in any one module shows up as a per-step delta instead of a single end-to-end number. Heap bounds catch unintended allocations: each step's delta vs the previous step is asserted against max_delta_bytes (only meaningful on ESP32 where freeHeap() returns a real value).", "steps": [ @@ -114,10 +114,10 @@ }, { "name": "add-artnet", - "description": "ArtNetSendDriver under Drivers. Full pipeline now end-to-end.", + "description": "NetworkSendDriver under Drivers. Full pipeline now end-to-end.", "op": "add_module", "id": "ArtNet", - "type": "ArtNetSendDriver", + "type": "NetworkSendDriver", "parent_id": "Drivers" }, { diff --git a/test/scenarios/light/scenario_Layer_memory_1to1.json b/test/scenarios/light/scenario_Layer_memory_1to1.json index c90eb665..3b9b6ec3 100644 --- a/test/scenarios/light/scenario_Layer_memory_1to1.json +++ b/test/scenarios/light/scenario_Layer_memory_1to1.json @@ -58,10 +58,10 @@ }, { "name": "add-artnet", - "description": "Add ArtNetSendDriver and run the bounded FPS measurement on the no-LUT path.", + "description": "Add NetworkSendDriver and run the bounded FPS measurement on the no-LUT path.", "op": "add_module", "id": "ArtNet", - "type": "ArtNetSendDriver", + "type": "NetworkSendDriver", "parent_id": "Drivers", "measure": true, "bounds": { diff --git a/test/scenarios/light/scenario_Layouts_mutation.json b/test/scenarios/light/scenario_Layouts_mutation.json index 60645fcc..926598c5 100644 --- a/test/scenarios/light/scenario_Layouts_mutation.json +++ b/test/scenarios/light/scenario_Layouts_mutation.json @@ -8,7 +8,7 @@ "Layer", "RainbowEffect", "Drivers", - "ArtNetSendDriver" + "NetworkSendDriver" ], "description": "Tree mutation on the Layouts container while the pipeline runs: add a second layout (multiple layouts under one Layouts), replace a layout with a different type, and remove a layout. The check is that each mutation leaves the pipeline RENDERING — Layer + Drivers re-wire via buildState and the buffer stays non-null and non-zero. Mirrors the HTTP add/replace/delete handlers; exercises the runner's add_module / replace_module / remove_module ops. NOTE: the Layer renders a dense bounding-box buffer sized by the layouts' coordinate EXTENT, not the summed light count — layouts that overlap in coordinate space share voxels (two 64x64 grids both occupy x,y in 0..63). There are no per-layout coordinate offsets, so multiple layouts share the same coordinate box; these steps assert liveness, not buffer-size arithmetic. Grids are 64x64 so the tick stays above the host's microsecond clock at every step.", "fixture": [ @@ -60,7 +60,7 @@ "name": "fix-artnet", "op": "add_module", "id": "ArtNet", - "type": "ArtNetSendDriver", + "type": "NetworkSendDriver", "parent_id": "Drivers" } ], @@ -158,7 +158,7 @@ "pc-macos": { "tick_us": [ 9, - 30 + 46 ], "free_heap": [ 0, @@ -170,7 +170,7 @@ ], "at": [ "2026-06-05", - "2026-06-05" + "2026-06-11" ] }, "pc-windows": { @@ -232,7 +232,7 @@ "pc-macos": { "tick_us": [ 10, - 116 + 174 ], "free_heap": [ 0, @@ -244,7 +244,7 @@ ], "at": [ "2026-06-05", - "2026-06-07" + "2026-06-11" ] }, "pc-windows": { diff --git a/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json b/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json index e7431411..49c8d615 100644 --- a/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json +++ b/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json @@ -67,10 +67,10 @@ }, { "name": "add-artnet", - "description": "Add ArtNetSendDriver and run the bounded FPS measurement on the LUT path.", + "description": "Add NetworkSendDriver and run the bounded FPS measurement on the LUT path.", "op": "add_module", "id": "ArtNet", - "type": "ArtNetSendDriver", + "type": "NetworkSendDriver", "parent_id": "Drivers", "measure": true, "bounds": { diff --git a/test/scenarios/light/scenario_MultiplyModifier_pipeline.json b/test/scenarios/light/scenario_MultiplyModifier_pipeline.json index 288ceebf..e1b9a7f1 100644 --- a/test/scenarios/light/scenario_MultiplyModifier_pipeline.json +++ b/test/scenarios/light/scenario_MultiplyModifier_pipeline.json @@ -5,7 +5,7 @@ "also": [ "Layer", "NoiseEffect", - "ArtNetSendDriver" + "NetworkSendDriver" ], "description": "Pipeline with a mirror modifier: NoiseEffect renders one quadrant, MultiplyModifier reflects across X and Y to produce a kaleidoscope. Used to verify the MultiplyModifier wires into Layer cleanly and that the full pipeline still meets its FPS bound.", "steps": [ @@ -67,10 +67,10 @@ }, { "name": "add-artnet", - "description": "Add ArtNetSendDriver and run the bounded FPS measurement (mirror + LUT path must stay at >=80% of the rated FPS).", + "description": "Add NetworkSendDriver and run the bounded FPS measurement (mirror + LUT path must stay at >=80% of the rated FPS).", "op": "add_module", "id": "ArtNet", - "type": "ArtNetSendDriver", + "type": "NetworkSendDriver", "parent_id": "Drivers", "measure": true, "bounds": { diff --git a/test/scenarios/light/scenario_modifier_swap.json b/test/scenarios/light/scenario_modifier_swap.json index f9fbfc4a..d94752c8 100644 --- a/test/scenarios/light/scenario_modifier_swap.json +++ b/test/scenarios/light/scenario_modifier_swap.json @@ -9,7 +9,7 @@ "Layouts", "GridLayout", "Drivers", - "ArtNetSendDriver", + "NetworkSendDriver", "PreviewDriver" ], "description": "Swap the Layer's modifier between Multiply and Checkerboard and verify the pipeline stays live across each replace. Prepares its own canvas (clear + rebuild) so it runs from any device state: one Layout(Grid 32x32) + one Layer + one effect + one modifier, then replace_module cycles the modifier MOD slot Multiply -> Checkerboard -> Multiply, measuring after each so a broken swap (null buffer / wrong light count) shows up. Exercises the modifier-replace path the UI's drag-replace uses.", @@ -77,7 +77,7 @@ "name": "fix-artnet", "op": "add_module", "id": "ArtNet", - "type": "ArtNetSendDriver", + "type": "NetworkSendDriver", "parent_id": "Drivers" } ], @@ -152,7 +152,7 @@ "pc-macos": { "tick_us": [ 6, - 7 + 13 ], "free_heap": [ 0, @@ -164,7 +164,7 @@ ], "at": [ "2026-06-07", - "2026-06-07" + "2026-06-11" ] }, "esp32-eth": { @@ -202,7 +202,7 @@ "pc-macos": { "tick_us": [ 17, - 20 + 57 ], "free_heap": [ 0, @@ -214,7 +214,7 @@ ], "at": [ "2026-06-07", - "2026-06-08" + "2026-06-11" ] }, "esp32-eth": { @@ -252,7 +252,7 @@ "pc-macos": { "tick_us": [ 6, - 8 + 15 ], "free_heap": [ 0, @@ -264,7 +264,7 @@ ], "at": [ "2026-06-07", - "2026-06-08" + "2026-06-11" ] }, "esp32-eth": { diff --git a/test/unit/core/conditional_controls.h b/test/unit/core/conditional_controls.h new file mode 100644 index 00000000..3a57f232 --- /dev/null +++ b/test/unit/core/conditional_controls.h @@ -0,0 +1,85 @@ +// Shared helpers for testing CONDITIONAL CONTROLS — controls whose UI visibility +// (the `hidden` flag) depends on another control's value. Used by every module +// that has them (NetworkModule's static-IP fields, RmtLedDriver's loopbackRxPin). +// +// The invariant these helpers pin (see docs/architecture.md § Conditional controls): +// 1. A conditional control is ALWAYS bound (present in the control list) so +// persistence can load its value regardless of the live conditional state. +// 2. Its `hidden` flag correctly reflects the conditioning control's value. +// 3. rebuildControls() re-evaluates the flag — the mechanism the UI relies on +// to reveal/hide a control live (HttpServerModule rebuilds after every change). +// +// Two real bugs motivated these tests: RmtLedDriver's rxPin not showing when the +// test was enabled, and a UI re-render loop when NetworkModule's static fields +// toggled. The host-side half (binding + flag) is what a unit test can prove. + +#pragma once + +#include "doctest.h" +#include "core/MoonModule.h" +#include +#include + +namespace mm::test { + +// Index of a control by name in a module's current control list, or -1 if absent +// (not bound at all). Use to assert a conditional control stays bound. +inline int controlIndex(mm::MoonModule& m, const char* name) { + const auto& cs = m.controls(); + for (uint8_t i = 0; i < cs.count(); i++) { + if (std::strcmp(cs[i].name, name) == 0) return static_cast(i); + } + return -1; +} + +// True if `name` is bound AND not hidden (i.e. the UI would render it). +inline bool isVisible(mm::MoonModule& m, const char* name) { + int i = controlIndex(m, name); + return i >= 0 && !m.controls()[static_cast(i)].hidden; +} + +// True if `name` is bound but hidden. +inline bool isHidden(mm::MoonModule& m, const char* name) { + int i = controlIndex(m, name); + return i >= 0 && m.controls()[static_cast(i)].hidden; +} + +// Write a value through a control's bound variable pointer, by name. Lets a test +// flip a conditioning control (e.g. NetworkModule's addressing) without a public +// setter — the same pointer the persistence/API paths write. Returns false if the +// control isn't bound. Caller must pass the matching integer width for the type. +template +inline bool setControlValue(mm::MoonModule& m, const char* name, T value) { + int i = controlIndex(m, name); + if (i < 0) return false; + void* p = m.controls()[static_cast(i)].ptr; + if (!p) return false; + *static_cast(p) = value; + return true; +} + +// Assert a conditional control obeys the full invariant across both states of its +// condition. `setCondition` flips the conditioning value to the given bool; the +// test toggles it both ways, rebuilds, and checks the dependent control is +// always bound and only visible when expected. +// +// visibleWhenTrue: does the dependent control show when the condition is true? +template +inline void checkConditionalControl(mm::MoonModule& m, const char* dependent, + SetCondition setCondition, bool visibleWhenTrue) { + // Condition TRUE → dependent visible (or hidden, per visibleWhenTrue), still bound. + setCondition(true); + m.rebuildControls(); + CHECK_MESSAGE(controlIndex(m, dependent) >= 0, + "conditional control must stay BOUND even when its condition is true: ", dependent); + CHECK(isVisible(m, dependent) == visibleWhenTrue); + + // Condition FALSE → visibility flips, STILL bound (persistence can load it). + setCondition(false); + m.rebuildControls(); + CHECK_MESSAGE(controlIndex(m, dependent) >= 0, + "conditional control must stay BOUND even when hidden (persistence needs it): ", dependent); + CHECK(isVisible(m, dependent) == !visibleWhenTrue); +} + +} // namespace mm::test diff --git a/test/unit/core/unit_NetworkModule.cpp b/test/unit/core/unit_NetworkModule.cpp index 667f016c..3889733f 100644 --- a/test/unit/core/unit_NetworkModule.cpp +++ b/test/unit/core/unit_NetworkModule.cpp @@ -12,6 +12,7 @@ #include "doctest.h" #include "platform_config.h" // pulls in platform::hasWiFi before NetworkModule.h #include "core/NetworkModule.h" +#include "conditional_controls.h" // shared conditional-control invariant helpers #include @@ -184,3 +185,23 @@ TEST_CASE("NetworkModule rssi/txPower controls hidden in non-WiFi states") { CHECK(matchCount == 0); } } + +// Conditional controls: the static-IP fields (ip/gateway/subnet/dns) are visible +// only when addressing == Static (1), hidden under DHCP (0) — but ALWAYS bound so +// persistence can load a saved static config regardless of the live mode. This is +// the documented add-then-setHidden pattern (architecture.md § Conditional +// controls); the test pins it both ways so a regression (e.g. dropping setHidden, +// or conditionally NOT adding the field) fails here, not on hardware. +TEST_CASE("NetworkModule static-IP fields track the addressing mode") { + mm::NetworkModule net; + net.setup(); // builds controls once (desktop cascade lands on AP/Idle) + + // addressing is the Select that conditions the static fields. setCondition(true) + // → Static (value 1) → fields visible; setCondition(false) → DHCP (0) → hidden. + auto setStatic = [&](bool on) { + mm::test::setControlValue(net, "addressing", on ? uint8_t{1} : uint8_t{0}); + }; + for (const char* field : {"ip", "gateway", "subnet", "dns"}) { + mm::test::checkConditionalControl(net, field, setStatic, /*visibleWhenTrue=*/true); + } +} diff --git a/test/unit/light/unit_ArtNetSendDriver_packet.cpp b/test/unit/light/unit_ArtNetSendDriver_packet.cpp deleted file mode 100644 index 20ee91b9..00000000 --- a/test/unit/light/unit_ArtNetSendDriver_packet.cpp +++ /dev/null @@ -1,90 +0,0 @@ -// @module ArtNetSendDriver - -#include "doctest.h" -#include "light/drivers/ArtNetSendDriver.h" - -#include - -// The built packet contains the exact header layout the Art-Net spec mandates: ID, OpCode, version, sequence, physical, universe, length, data. -TEST_CASE("ArtNet packet header format") { - uint8_t data[3] = {255, 0, 128}; - uint8_t packet[mm::ArtNetSendDriver::ARTNET_HEADER_SIZE + 3]; - - size_t len = mm::ArtNetSendDriver::buildPacket(packet, 0, 42, data, 3); - - CHECK(len == mm::ArtNetSendDriver::ARTNET_HEADER_SIZE + 3); - - // "Art-Net\0" at offset 0 - CHECK(std::memcmp(packet, "Art-Net", 8) == 0); - - // OpCode: 0x5000 little-endian at offset 8 - CHECK(packet[8] == 0x00); - CHECK(packet[9] == 0x50); - - // Protocol version: 14 big-endian at offset 10 - CHECK(packet[10] == 0x00); - CHECK(packet[11] == 0x0e); - - // Sequence at offset 12 - CHECK(packet[12] == 42); - - // Physical at offset 13 - CHECK(packet[13] == 0); - - // Universe: 0 little-endian at offset 14 - CHECK(packet[14] == 0x00); - CHECK(packet[15] == 0x00); - - // Length: 3 big-endian at offset 16 - CHECK(packet[16] == 0x00); - CHECK(packet[17] == 0x03); - - // Data at offset 18 - CHECK(packet[18] == 255); - CHECK(packet[19] == 0); - CHECK(packet[20] == 128); -} - -// Universe 259 (0x0103) is encoded little-endian (low byte first), matching the Art-Net wire format. -TEST_CASE("ArtNet packet with non-zero universe") { - uint8_t data[6] = {1, 2, 3, 4, 5, 6}; - uint8_t packet[mm::ArtNetSendDriver::ARTNET_HEADER_SIZE + 6]; - - mm::ArtNetSendDriver::buildPacket(packet, 259, 0, data, 6); - - // Universe 259 = 0x0103, little-endian - CHECK(packet[14] == 0x03); - CHECK(packet[15] == 0x01); -} - -// 256 RGB lights (768 bytes) split across exactly 2 universes (510 + 258), matching the 510-channel-per-universe cap. -TEST_CASE("ArtNet universe splitting for 256 RGB lights") { - // 256 RGB lights = 768 bytes = 2 universes (510 + 258) - constexpr size_t maxPerUniverse = mm::ArtNetSendDriver::MAX_CHANNELS_PER_UNIVERSE; - constexpr size_t totalBytes = 256 * 3; - - size_t universeCount = 0; - size_t sent = 0; - while (sent < totalBytes) { - size_t chunk = totalBytes - sent; - if (chunk > maxPerUniverse) chunk = maxPerUniverse; - sent += chunk; - universeCount++; - } - - CHECK(universeCount == 2); - CHECK(sent == totalBytes); -} - -// The data-length field is encoded big-endian (high byte first), unlike the universe field — matching the Art-Net spec. -TEST_CASE("ArtNet packet length field is big-endian") { - uint8_t data[510]; - std::memset(data, 0, sizeof(data)); - uint8_t packet[mm::ArtNetSendDriver::ARTNET_HEADER_SIZE + 510]; - - mm::ArtNetSendDriver::buildPacket(packet, 0, 0, data, 510); - - // 510 = 0x01FE, big-endian - CHECK(packet[16] == 0x01); - CHECK(packet[17] == 0xFE); -} diff --git a/test/unit/light/unit_AudioBands.cpp b/test/unit/light/unit_AudioBands.cpp new file mode 100644 index 00000000..5324060d --- /dev/null +++ b/test/unit/light/unit_AudioBands.cpp @@ -0,0 +1,139 @@ +// @module AudioModule +// @also AudioSpectrumEffect + +#include "doctest.h" +#include "core/AudioBands.h" +#include "platform/platform.h" // platform::audioFft (desktop naive DFT) + +#include +#include +#include + +// The success spec for the frequency path, written RED before AudioModule's FFT +// call exists. The whole pipeline runs host-side: synthesize a sine -> +// applyWindow -> platform::audioFft (the desktop reference DFT) -> +// magnitudesToBands, then assert the energy lands in the right band and the +// reported peak frequency tracks the tone. This is the coverage that lets the +// band-map tuning happen in CI instead of on the bench over months. + +namespace { + +constexpr size_t kN = 256; // FFT size (power of two) +constexpr uint32_t kRate = 22050; // default sample rate + +// Build kN samples of a sine at `freqHz`, 24-bit amplitude, in the int32 slot. +std::vector tone(double freqHz, double amp24 = (1 << 21)) { + constexpr double kPi = std::numbers::pi_v; + std::vector v(kN); + const double cycles = freqHz * kN / kRate; + for (size_t i = 0; i < kN; i++) { + const double s = amp24 * std::sin(2.0 * kPi * cycles * static_cast(i) / kN); + const int64_t sample = static_cast(s) << 8; // avoid signed <<8 UB + v[i] = static_cast(sample); + } + return v; +} + +// Run the full window -> FFT -> bands pipeline; return the frame fields of +// interest. noiseFloor=80 / gain=80 set a dB window (~100 dB floor, ~40 dB span) +// that brackets the test tones' ~125 dB level, so a dominant band stands clearly +// above the others instead of every band saturating at the top of the window. +void analyse(const std::vector& samples, uint8_t (&bands)[16], + uint16_t& peakHz, uint16_t& peakMag) { + std::vector windowed(kN), mag(kN / 2); + mm::applyWindow(samples.data(), kN, windowed.data()); + mm::platform::audioFft(windowed.data(), kN, mag.data()); + mm::magnitudesToBands(mag.data(), kN / 2, kRate, /*noiseFloor*/80, /*gain*/80, bands, peakHz, peakMag); +} + +// Index of the loudest band. +int dominantBand(const uint8_t (&bands)[16]) { + int best = 0; + for (int b = 1; b < 16; b++) if (bands[b] > bands[best]) best = b; + return best; +} + +} // namespace + +TEST_CASE("AudioBands: silence yields all-zero bands and no peak") { + std::vector s(kN, 0); + uint8_t bands[16]; + uint16_t peakHz = 9999, peakMag = 9999; + analyse(s, bands, peakHz, peakMag); + for (int b = 0; b < 16; b++) CHECK(bands[b] == 0); + CHECK(peakHz == 0); + CHECK(peakMag == 0); +} + +TEST_CASE("AudioBands: a low tone lands in a low band, a high tone in a high band") { + uint8_t lo[16], hi[16]; + uint16_t hz, mag; + analyse(tone(500.0), lo, hz, mag); // bass + analyse(tone(8000.0), hi, hz, mag); // treble (under the ~11 kHz Nyquist) + const int loBand = dominantBand(lo); + const int hiBand = dominantBand(hi); + CHECK(hiBand > loBand); // higher frequency → higher band index +} + +TEST_CASE("AudioBands: the reported peak frequency tracks the played tone") { + for (double f : {1000.0, 3000.0, 6000.0}) { + uint8_t bands[16]; + uint16_t peakHz, peakMag; + analyse(tone(f), bands, peakHz, peakMag); + REQUIRE(peakMag > 0); + // Within one FFT bin (rate/N ≈ 86 Hz) of the true tone. + const double binHz = static_cast(kRate) / kN; + CHECK(std::abs(static_cast(peakHz) - f) <= binHz * 1.5); + } +} + +TEST_CASE("AudioBands: a single tone concentrates energy, not smears it everywhere") { + uint8_t bands[16]; + uint16_t peakHz, peakMag; + analyse(tone(2000.0), bands, peakHz, peakMag); + const int dom = dominantBand(bands); + // The dominant band is clearly above the average — the window/FFT isolated it. + int sum = 0; + for (int b = 0; b < 16; b++) sum += bands[b]; + const int avg = sum / 16; + CHECK(bands[dom] > avg * 2); +} + +TEST_CASE("AudioBands: noiseFloor gates a low idle spectrum to zero, gain scales it back") { + // A weak tone that lands a small but nonzero band magnitude at unity gain. + auto quiet = tone(2000.0, 1 << 12); // small amplitude + std::vector windowed(kN), mag(kN / 2); + mm::applyWindow(quiet.data(), kN, windowed.data()); + mm::platform::audioFft(windowed.data(), kN, mag.data()); + + uint8_t open[16], gated[16]; + uint16_t hz, pm; + mm::magnitudesToBands(mag.data(), kN / 2, kRate, /*noiseFloor*/0, 16, open, hz, pm); + int openMax = 0; + for (int b = 0; b < 16; b++) if (open[b] > openMax) openMax = open[b]; + REQUIRE(openMax > 0); // something lights with the gate open + + // A noiseFloor above that level zeroes every band — the fix for idle flicker. + mm::magnitudesToBands(mag.data(), kN / 2, kRate, + static_cast(openMax + 50), 16, gated, hz, pm); + for (int b = 0; b < 16; b++) CHECK(gated[b] == 0); +} + +TEST_CASE("AudioBands: zero / degenerate input never crashes") { + uint8_t bands[16]; + uint16_t peakHz, peakMag; + mm::magnitudesToBands(nullptr, 8, kRate, 0, 16, bands, peakHz, peakMag); + CHECK(peakMag == 0); + float mag1 = 1.0f; + mm::magnitudesToBands(&mag1, 0, kRate, 0, 16, bands, peakHz, peakMag); // nMag 0 + CHECK(peakMag == 0); + mm::magnitudesToBands(&mag1, 1, 0, 0, 16, bands, peakHz, peakMag); // rate 0 + CHECK(peakMag == 0); + + // applyWindow on degenerate sizes is a no-op, not a crash. + float out1 = 123.0f; + int32_t s = 7 << 8; + mm::applyWindow(&s, 0, &out1); + mm::applyWindow(nullptr, 4, &out1); + CHECK(true); +} diff --git a/test/unit/light/unit_AudioLevel.cpp b/test/unit/light/unit_AudioLevel.cpp new file mode 100644 index 00000000..bb6af69a --- /dev/null +++ b/test/unit/light/unit_AudioLevel.cpp @@ -0,0 +1,189 @@ +// @module AudioModule +// @also AudioVolumeEffect + +#include "doctest.h" +#include "core/AudioLevel.h" +#include "core/AudioModule.h" +#include "core/ModuleFactory.h" +#include "light/effects/AudioVolumeEffect.h" +#include "light/effects/AudioSpectrumEffect.h" + +#include +#include +#include +#include + +// The success spec for the level path, written RED before AudioModule's reader +// exists: the two I2S-mic facts that AudioLevel.h handles must hold on +// synthesized blocks — DC offset is removed (a biased-but-silent block reads 0), +// the noise floor gates quiet hiss, gain scales what survives — and the whole +// thing is crash-safe on empty/degenerate input. + +namespace { + +// A pure sine of `cycles` periods across `n` samples at 24-bit-ish amplitude, +// left-justified into the int32 slot (<<8) the INMP441 produces, plus an +// optional DC bias to prove the bias is stripped. +std::vector sine(size_t n, double cycles, double amp24, double dc24 = 0.0) { + constexpr double kPi = std::numbers::pi_v; + std::vector v(n); + for (size_t i = 0; i < n; i++) { + const double s = amp24 * std::sin(2.0 * kPi * cycles * static_cast(i) / n) + dc24; + // <<8 on a wider signed type — left-shifting a negative int32 is UB. + const int64_t sample = static_cast(s) << 8; // 24-bit into the high bits + v[i] = static_cast(sample); + } + return v; +} + +} // namespace + +TEST_CASE("DcBlocker: a constant DC offset is filtered out") { + mm::DcBlocker dc; + std::vector s(1024, 100000); // a big constant offset, no AC + dc.process(s.data(), s.size()); + // After the filter settles, the output rides near zero (the DC is gone). + int32_t lo = s[1023], hi = s[1023]; + for (size_t i = 900; i < s.size(); i++) { // look past the transient + if (s[i] < lo) lo = s[i]; + if (s[i] > hi) hi = s[i]; + } + CHECK(std::abs(lo) < 2000); + CHECK(std::abs(hi) < 2000); +} + +TEST_CASE("DcBlocker: an audio tone passes through (DC removed, AC kept)") { + mm::DcBlocker dc; + // A mid-frequency sine on a big DC pedestal — the DC must go, the swing stay. + const int32_t amp = 1 << 18; + auto biased = sine(1024, 40, amp, 1 << 21); // amp on a much larger DC pedestal + dc.process(biased.data(), biased.size()); + int32_t lo = biased[512], hi = biased[512]; + for (size_t i = 512; i < biased.size(); i++) { // past the transient + if (biased[i] < lo) lo = biased[i]; + if (biased[i] > hi) hi = biased[i]; + } + const int32_t swing = hi - lo; + // Centred near zero (DC removed): the midpoint is tiny vs the swing. + CHECK(std::abs(lo + hi) < swing / 4); + // The tone survived: the swing is on the order of the AC amplitude (<<8 slot). + CHECK(swing > amp); +} + +TEST_CASE("DcBlocker: reset clears state, null-safe") { + mm::DcBlocker dc; + std::vector s(64, 50000); + dc.process(s.data(), s.size()); + dc.reset(); + CHECK(dc.xPrev == 0.0f); + CHECK(dc.yPrev == 0.0f); + dc.process(nullptr, 64); // no crash + CHECK(true); +} + +TEST_CASE("AudioLevel: silence reads zero") { + std::vector s(512, 0); + mm::AudioFrame f; + mm::computeLevel(s.data(), s.size(), /*noiseFloor*/0, /*gain*/16, f); + CHECK(f.level == 0); +} + +TEST_CASE("AudioLevel: pure DC reads zero (DC offset stripped)") { + // A big constant bias, no AC — the DC must be stripped: RMS ~0, not huge. + std::vector s(512, (1 << 22) << 8); + mm::AudioFrame f; + mm::computeLevel(s.data(), s.size(), 0, 16, f); + CHECK(f.level == 0); +} + +TEST_CASE("AudioLevel: a loud sine reads a higher level than a quiet one") { + // A wide dB window (gain 40) so both land inside it, not both at 255. + auto loud = sine(512, 8, 1 << 14); + auto quiet = sine(512, 8, 1 << 11); + mm::AudioFrame fl, fq; + mm::computeLevel(loud.data(), loud.size(), 0, 40, fl); + mm::computeLevel(quiet.data(), quiet.size(), 0, 40, fq); + CHECK(fl.level > fq.level); // the log-scaled level tracks amplitude +} + +TEST_CASE("AudioLevel: DC bias does not change the level of a sine") { + auto clean = sine(512, 8, 1 << 14, 0.0); + auto biased = sine(512, 8, 1 << 14, 1 << 22); // same AC, huge DC + mm::AudioFrame fc, fb; + mm::computeLevel(clean.data(), clean.size(), 0, 40, fc); + mm::computeLevel(biased.data(), biased.size(), 0, 40, fb); + // The DC strip makes the two read the same level (within quantisation). + const int diff = static_cast(fc.level) - static_cast(fb.level); + CHECK(std::abs(diff) <= 2); +} + +TEST_CASE("AudioLevel: a high noiseFloor (dB floor) gates a modest signal to zero") { + auto s = sine(512, 8, 1 << 14); // a modest level + mm::AudioFrame lo, hi; + mm::computeLevel(s.data(), s.size(), /*noiseFloor*/0, /*gain*/40, lo); + REQUIRE(lo.level > 0); // shows with a low floor + // Raising the dB floor above the signal's level zeroes the displayed level. + mm::computeLevel(s.data(), s.size(), /*noiseFloor*/255, /*gain*/40, hi); + CHECK(hi.level == 0); +} + +TEST_CASE("AudioLevel: higher gain (narrower dB window) reads a higher level") { + auto s = sine(512, 8, 1 << 14); + mm::AudioFrame lo, hi; + mm::computeLevel(s.data(), s.size(), 0, /*gain*/20, lo); // wide window + mm::computeLevel(s.data(), s.size(), 0, /*gain*/120, hi); // narrower = hotter + REQUIRE(lo.level > 0); + CHECK(hi.level > lo.level); +} + +TEST_CASE("AudioLevel: empty / null input is silence, never a crash") { + mm::AudioFrame f; + mm::computeLevel(nullptr, 512, 0, 16, f); + CHECK(f.level == 0); + int32_t dummy = 0; + mm::computeLevel(&dummy, 0, 0, 16, f); + CHECK(f.level == 0); +} + +TEST_CASE("AudioLevel: isqrt64 matches floor(sqrt) on a spread of values") { + const uint64_t xs[] = {0, 1, 2, 3, 4, 99, 100, 12345, 1ull << 40, (1ull << 46) + 7}; + for (uint64_t x : xs) { + const uint64_t r = mm::isqrt64(x); + CHECK(r * r <= x); + CHECK((r + 1) * (r + 1) > x); + } +} + +// Regression: the boot wiring in main.cpp does +// create("AudioModule")->markWiredByCode() +// and create() returns nullptr for an UNREGISTERED type — so a missing +// registerType made the deref crash and the device boot-looped (found +// on the S3 bench). These pin that AudioModule and the two audio effects are all +// registered + createable through the factory, and that latestFrame() is never +// null even with no mic (so a consumer added before the mic can't deref null). +TEST_CASE("AudioModule + audio effects are registered and createable (boot-loop guard)") { + mm::ModuleFactory::registerType("AudioModule"); + mm::ModuleFactory::registerType("AudioVolumeEffect"); + mm::ModuleFactory::registerType("AudioSpectrumEffect"); + + auto* mic = mm::ModuleFactory::create("AudioModule"); + REQUIRE(mic != nullptr); + CHECK(mic->role() == mm::ModuleRole::Peripheral); + + auto* vol = mm::ModuleFactory::create("AudioVolumeEffect"); + auto* spec = mm::ModuleFactory::create("AudioSpectrumEffect"); + REQUIRE(vol != nullptr); + REQUIRE(spec != nullptr); + + delete mic; + delete vol; + delete spec; +} + +TEST_CASE("AudioModule::latestFrame is never null (silent frame with no active mic)") { + const mm::AudioFrame* f = mm::AudioModule::latestFrame(); + REQUIRE(f != nullptr); + // With no mic having run setup(), it's the static silent frame. + CHECK(f->level == 0); + CHECK(f->peakHz == 0); +} diff --git a/test/unit/light/unit_AudioModule.cpp b/test/unit/light/unit_AudioModule.cpp new file mode 100644 index 00000000..d140e9a0 --- /dev/null +++ b/test/unit/light/unit_AudioModule.cpp @@ -0,0 +1,88 @@ +// @module AudioModule + +#include "doctest.h" +#include "core/AudioModule.h" + +#include + +// The MoonModule lifecycle for the audio peripheral: setup/teardown is +// repeatable and leaves no residue, the static latestFrame() accessor stays +// coherent through any add/remove order (the robustness rule), and a module +// that is never configured stays idle. The signal math (level, bands, FFT) is +// covered by unit_AudioLevel.cpp / unit_AudioBands.cpp; this file owns the +// module's own state machine, which is what the classic-ESP32 boot-loop showed +// was the risky part. Host-side: hasI2sMic is false on desktop, so reinit() +// settles on the platform-inert path (no I2S init) — exactly the state a +// mic-less board boots into — and these pin that that state is clean, not a +// crash or a hang (the bug class behind the boot-loop). + +// active_ is process-wide static and shared with the cases in unit_AudioLevel.cpp; +// each case here brackets its own setup() with a teardown() so it never leaks a +// live mic pointer into another test (the residue the robustness rule forbids). + +TEST_CASE("AudioModule: a fresh, unconfigured module is idle (pins default unset)") { + mm::AudioModule a; + a.onBuildControls(); + // Pins default to 0 (unset): the module is user-added when a board has a mic + // and waits for the real GPIOs. It must never have inited a mic by merely + // existing — the auto-init-on-boot path is what hung a mic-less classic. + CHECK(a.wsPin == 0); + CHECK(a.sdPin == 0); + CHECK(a.sckPin == 0); + a.setup(); // settles the status; no I2S touched on host or unset pins + a.loop(); // must be a quiet no-op, not a crash, with nothing inited + a.teardown(); + CHECK(true); +} + +TEST_CASE("AudioModule: setup/teardown is repeatable with no residual state") { + mm::AudioModule a; + a.onBuildControls(); + const char* afterFirst = nullptr; + for (int cycle = 0; cycle < 4; cycle++) { + a.setup(); + a.onBuildState(); // reinit, as the Scheduler does + a.loop(); // a tick: no heap churn, no crash (ASAN across cycles) + a.teardown(); + // The status settles to a stable value, not a growing/changing string — + // a leak or churn would show up as a different pointer each cycle. On host + // (hasI2sMic false) that value is the platform-inert note; on a mic target + // with unset pins it's the "set pins" note. Either way: same every cycle. + if (cycle == 0) afterFirst = a.status(); + else CHECK(a.status() == afterFirst); + } +} + +TEST_CASE("AudioModule: teardown clears the active mic (latestFrame falls back to silence)") { + { + mm::AudioModule a; + a.onBuildControls(); + a.setup(); // registers itself as active_ + REQUIRE(mm::AudioModule::latestFrame() == a.audioFrame()); + a.teardown(); // must release the registration + } + // After teardown (and destruction) no mic is active: the accessor returns the + // static silent frame, never a dangling pointer into the destroyed module. + const mm::AudioFrame* f = mm::AudioModule::latestFrame(); + REQUIRE(f != nullptr); + CHECK(f->level == 0); + CHECK(f->peakHz == 0); +} + +TEST_CASE("AudioModule: last setup() wins, any add/remove order stays coherent") { + // The robustness rule: add/remove modules in any order, the answer stays valid. + mm::AudioModule a, b; + a.onBuildControls(); + b.onBuildControls(); + + a.setup(); + CHECK(mm::AudioModule::latestFrame() == a.audioFrame()); + b.setup(); // b is now the active mic + CHECK(mm::AudioModule::latestFrame() == b.audioFrame()); + + a.teardown(); // tearing down the INACTIVE one + CHECK(mm::AudioModule::latestFrame() == b.audioFrame()); // must not disturb b + b.teardown(); + // Both gone: back to the static silence, no dangling pointer. + CHECK(mm::AudioModule::latestFrame()->level == 0); +} diff --git a/test/unit/light/unit_CheckerboardEffect.cpp b/test/unit/light/unit_CheckerboardEffect.cpp index 6ddab721..13d3ba22 100644 --- a/test/unit/light/unit_CheckerboardEffect.cpp +++ b/test/unit/light/unit_CheckerboardEffect.cpp @@ -1,10 +1,11 @@ // @module CheckerboardEffect -// @also SpiralEffect, PlasmaPaletteEffect, RipplesEffect, GlowParticlesEffect, LavaLampEffect +// @also SpiralEffect, PlasmaPaletteEffect, RingsEffect, RipplesEffect, GlowParticlesEffect, LavaLampEffect #include "doctest.h" #include "light/effects/CheckerboardEffect.h" #include "light/effects/SpiralEffect.h" #include "light/effects/PlasmaPaletteEffect.h" +#include "light/effects/RingsEffect.h" #include "light/effects/RipplesEffect.h" #include "light/effects/GlowParticlesEffect.h" #include "light/effects/LavaLampEffect.h" @@ -108,7 +109,7 @@ STATELESS_EFFECT_TEST(GlowParticlesEffect) // LavaLampEffect has localised blob features that can land on identical corner // palette indices at some t values (corner-pair check is too strict). Scan the // whole buffer for any two distinct pixels instead — same approach as -// RipplesEffect below. +// RingsEffect below. // LavaLamp paints at least one non-zero byte (effect actually renders). TEST_CASE("LavaLampEffect writes non-zero RGB") { Ctx ctx(16, 16); @@ -145,9 +146,31 @@ TEST_CASE("LavaLampEffect spatial variation") { CHECK(varied); } -// RipplesEffect has localised features (thin rings); corner-pair check is +// RingsEffect has localised features (thin rings); corner-pair check is // too strict, so we scan for any two distinct pixels instead. -// Ripples paints at least one non-zero byte (effect actually renders). +// Rings paints at least one non-zero byte (effect actually renders). +TEST_CASE("RingsEffect writes non-zero RGB") { + Ctx ctx(16, 16); + mm::RingsEffect effect; + ctx.layer.addChild(&effect); + ctx.layer.onBuildState(); + ctx.layer.loop(); + CHECK(ctx.hasNonZero()); +} + +// At least two distinct pixels exist somewhere in the buffer (rings are localised, so corner-pair would be too strict). +TEST_CASE("RingsEffect spatial variation") { + Ctx ctx(32, 32); + mm::RingsEffect effect; + ctx.layer.addChild(&effect); + ctx.layer.onBuildState(); + ctx.layer.loop(); + CHECK(ctx.hasTwoDistinctColors()); +} + +// RipplesEffect (MoonLight sine-wave water surface) lights one pixel per column +// at a sine-driven height. On a flat 2D layer it still paints a visible wavefront +// — assert it renders something and varies across the surface. TEST_CASE("RipplesEffect writes non-zero RGB") { Ctx ctx(16, 16); mm::RipplesEffect effect; @@ -157,7 +180,7 @@ TEST_CASE("RipplesEffect writes non-zero RGB") { CHECK(ctx.hasNonZero()); } -// At least two distinct pixels exist somewhere in the buffer (ripples are localised, so corner-pair would be too strict). +// Ripples lights one pixel per column at a sine-driven height, so the surface holds at least two distinct colours (wavefront vs background) — scan the whole buffer, corner-pair would be too strict. TEST_CASE("RipplesEffect spatial variation") { Ctx ctx(32, 32); mm::RipplesEffect effect; diff --git a/test/unit/light/unit_Layer_zero_grid.cpp b/test/unit/light/unit_Layer_zero_grid.cpp index 493c9b04..f702af37 100644 --- a/test/unit/light/unit_Layer_zero_grid.cpp +++ b/test/unit/light/unit_Layer_zero_grid.cpp @@ -1,5 +1,5 @@ // @module Layer -// @also RainbowEffect, NoiseEffect, PlasmaEffect, CheckerboardEffect, SpiralEffect, MetaballsEffect, PlasmaPaletteEffect, RipplesEffect, GlowParticlesEffect, LavaLampEffect, FireEffect, ParticlesEffect +// @also RainbowEffect, NoiseEffect, PlasmaEffect, CheckerboardEffect, SpiralEffect, MetaballsEffect, PlasmaPaletteEffect, RingsEffect, RipplesEffect, GlowParticlesEffect, LavaLampEffect, FireEffect, ParticlesEffect #include "doctest.h" #include "light/layers/Layer.h" @@ -12,6 +12,7 @@ #include "light/effects/SpiralEffect.h" #include "light/effects/MetaballsEffect.h" #include "light/effects/PlasmaPaletteEffect.h" +#include "light/effects/RingsEffect.h" #include "light/effects/RipplesEffect.h" #include "light/effects/GlowParticlesEffect.h" #include "light/effects/LavaLampEffect.h" @@ -62,6 +63,8 @@ TEST_CASE("SpiralEffect on 0,0,0 grid") { run_with_empty_layout(); } // PlasmaPalette on 0,0,0 grid: no crash. TEST_CASE("PlasmaPaletteEffect on 0,0,0 grid"){run_with_empty_layout(); } +// Rings on 0,0,0 grid: no crash. +TEST_CASE("RingsEffect on 0,0,0 grid") { run_with_empty_layout(); } // Ripples on 0,0,0 grid: no crash. TEST_CASE("RipplesEffect on 0,0,0 grid") { run_with_empty_layout(); } // GlowParticles on 0,0,0 grid: no crash. diff --git a/test/unit/light/unit_LcdLedDriver.cpp b/test/unit/light/unit_LcdLedDriver.cpp new file mode 100644 index 00000000..fab41a6f --- /dev/null +++ b/test/unit/light/unit_LcdLedDriver.cpp @@ -0,0 +1,199 @@ +// @module LcdLedDriver +// @also Drivers, Correction + +#include "doctest.h" +#include "light/drivers/Correction.h" +#include "light/drivers/LcdLedDriver.h" +#include "light/layers/Buffer.h" + +#include + +// Host-side half of the LCD driver: lane slicing (the shared PinList +// semantics), the frame-byte arithmetic (latch pad, 64-byte alignment, RGBW +// growth), and the parse-error/recovery shape. The hardware half (bus init, +// DMA transmit) is inert on the host — desktop stubs return false/nullptr — +// and is proven on the S3. + +namespace { + +void wire(mm::LcdLedDriver& d, mm::Buffer& src, mm::Correction& corr, + mm::nrOfLightsType lights) { + // Pins default to UNSET now (the "default only when it cannot do harm" rule — + // a user solders the strand to its own GPIOs), so a fresh driver idles until + // configured. These slicing/frame tests exercise the lane logic, not the + // default value, so the helper supplies the bench 8-pin set unless a case set + // its own pins first (the bad-pin / partial-bus cases do). + if (d.pins[0] == '\0') std::strcpy(d.pins, "1,2,4,5,6,7,8,9"); + // allocate succeeds exactly when lights > 0 (the zero-grid case wires an + // empty buffer on purpose); a masked alloc failure would fail cases downstream. + REQUIRE(src.allocate(lights, 3) == (lights > 0)); + corr.rebuild(255, mm::LightPreset::GRB); // 3 out-channels + d.onBuildControls(); + d.setSourceBuffer(&src); + d.setCorrection(&corr); + d.onBuildState(); +} + +// frameBytes = maxLaneLights × outCh × 24 + 800 latch pad + 64 clock-tolerance +// slack, rounded up to 64 (mirrors ParallelLedDriver::frameBytesFor). +size_t expectFrame(mm::nrOfLightsType maxLights, uint8_t outCh) { + if (maxLights == 0) return 0; + const size_t raw = static_cast(maxLights) * outCh * 24 + 800 + 64; + return (raw + 63) & ~static_cast(63); +} + +} // namespace + +// Explicit counts slice the buffer consecutively; the frame is sized by the +// LONGEST lane. The bus always has all 8 lanes — unused strands take the +// 0-light remainder and idle LOW. +TEST_CASE("LcdLedDriver slices lanes and sizes the frame by the longest") { + mm::LcdLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.ledsPerPin, "50,20,20"); // lanes 3..7 share the remainder: 0 + wire(d, src, corr, 90); + + REQUIRE(d.laneCount() == 8); + CHECK(d.laneLightCount(0) == 50); + CHECK(d.laneLightCount(1) == 20); + CHECK(d.laneLightCount(2) == 20); + CHECK(d.laneLightCount(3) == 0); + CHECK(d.laneLightCount(7) == 0); + CHECK(d.laneStart(0) == 0); + CHECK(d.laneStart(1) == 50); + CHECK(d.laneStart(2) == 70); + CHECK(d.maxLaneLights() == 50); + CHECK(d.frameBytes() == expectFrame(50, 3)); +} + +// Empty ledsPerPin splits evenly — same PinList semantics the RMT driver uses. +TEST_CASE("LcdLedDriver even split over the default 8 lanes") { + mm::LcdLedDriver d; + mm::Buffer src; + mm::Correction corr; + wire(d, src, corr, 256); // default pins: 8 lanes + + REQUIRE(d.laneCount() == 8); + CHECK(d.laneLightCount(0) == 32); + CHECK(d.laneLightCount(7) == 32); + CHECK(d.maxLaneLights() == 32); + CHECK(d.frameBytes() == expectFrame(32, 3)); +} + +// An RGB→RGBW preset toggle grows the frame (32 vs 24 slot bytes per light). +TEST_CASE("LcdLedDriver frame grows on RGBW preset") { + mm::LcdLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.ledsPerPin, "50,50"); // lanes 2..7 idle + wire(d, src, corr, 100); + CHECK(d.frameBytes() == expectFrame(50, 3)); + + corr.rebuild(255, mm::LightPreset::GRBW); + d.onCorrectionChanged(); + CHECK(d.frameBytes() == expectFrame(50, 4)); +} + +// A bad pin list idles the driver with the parse literal in the status; fixing it recovers. +TEST_CASE("LcdLedDriver bad pins → status error → recovery") { + mm::LcdLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.pins, "1,nope"); + wire(d, src, corr, 64); + + CHECK(d.laneCount() == 0); + CHECK(d.frameBytes() == 0); + CHECK(d.status() != nullptr); + + std::strcpy(d.pins, "1,2,4,5,6,7,8,9"); + d.onBuildState(); + CHECK(d.laneCount() == 8); + CHECK(d.status() == nullptr); +} + +// Pins now default UNSET (the "default only when it cannot do harm" rule — the +// strand is user-soldered). A fresh, unconfigured driver idles, never grabbing +// the 8 data GPIOs on its own. (wire() back-fills empty pins for the slicing +// cases, so this one wires the buffer directly to keep pins empty.) +TEST_CASE("LcdLedDriver with the empty default pins idles cleanly") { + mm::LcdLedDriver d; + mm::Buffer src; + mm::Correction corr; + REQUIRE(d.pins[0] == '\0'); // the empty default, not a bench guess + REQUIRE(src.allocate(64, 3)); + corr.rebuild(255, mm::LightPreset::GRB); + d.onBuildControls(); + d.setSourceBuffer(&src); + d.setCorrection(&corr); + d.onBuildState(); + + CHECK(d.laneCount() == 0); // no lanes claimed + CHECK(d.frameBytes() == 0); + CHECK(d.status() != nullptr); // "set pins" surfaced, not silent + d.loop(); // must be a no-op, not a crash +} + +// IDF's i80 bus rejects partial pin sets, so the driver does too — fewer than +// 8 pins is a config error, not a narrower bus. +TEST_CASE("LcdLedDriver requires exactly 8 pins") { + mm::LcdLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.pins, "1,2,4"); + wire(d, src, corr, 64); + + CHECK(d.laneCount() == 0); + CHECK(d.frameBytes() == 0); + REQUIRE(d.status() != nullptr); + CHECK(std::strcmp(d.status(), "LCD bus needs exactly 8 pins") == 0); +} + +// A 0×0×0 grid is a clean idle: zero counts, zero frame (no pad for an empty frame), no crash. +TEST_CASE("LcdLedDriver tolerates a zero-light buffer") { + mm::LcdLedDriver d; + mm::Buffer src; + mm::Correction corr; + wire(d, src, corr, 0); + + CHECK(d.laneCount() == 8); // pins parse fine + CHECK(d.maxLaneLights() == 0); + CHECK(d.frameBytes() == 0); + d.loop(); // must be a no-op, not a crash + CHECK(true); +} + +// setup/teardown cycles leave no residue (status clean, ASAN-checked heap). +TEST_CASE("LcdLedDriver setup/teardown is repeatable") { + mm::LcdLedDriver d; + mm::Buffer src; + mm::Correction corr; + src.allocate(64, 3); + corr.rebuild(255, mm::LightPreset::GRB); + std::strcpy(d.pins, "1,2,4,5,6,7,8,9"); // pins now default UNSET + d.onBuildControls(); + for (int cycle = 0; cycle < 4; cycle++) { + d.setup(); + d.setSourceBuffer(&src); + d.setCorrection(&corr); + d.onBuildState(); + REQUIRE(d.laneCount() == 8); + d.teardown(); + CHECK(d.status() == nullptr); + } +} + +// loopbackRxPin is bound always, visible only while loopbackTest is on. +TEST_CASE("LcdLedDriver loopbackRxPin tracks the loopbackTest toggle") { + mm::LcdLedDriver d; + d.onBuildControls(); + bool found = false; + for (uint8_t i = 0; i < d.controls().count(); i++) { + if (std::strcmp(d.controls()[i].name, "loopbackRxPin") == 0) { + found = true; + CHECK(d.controls()[i].hidden == true); // test mode off by default + } + } + CHECK(found); +} diff --git a/test/unit/light/unit_LcdLedEncoder.cpp b/test/unit/light/unit_LcdLedEncoder.cpp new file mode 100644 index 00000000..6ecbfb7d --- /dev/null +++ b/test/unit/light/unit_LcdLedEncoder.cpp @@ -0,0 +1,113 @@ +// @module LcdLedDriver +// @also Correction + +#include "doctest.h" +#include "light/drivers/Correction.h" +#include "light/drivers/LcdSlots.h" + +#include + +// The success spec for the LCD_CAM 3-slot encode, written RED before the +// encoder exists (the increment-1 methodology): a known wire row + lane mask +// → the exact slot-byte stream. Pins the transpose (one bus byte carries one +// bit of every lane), MSB-first bit order, and the lanes-active-mask rule +// that keeps short strands idle-LOW. + +namespace { + +// out holds channels*8 triplets of (slot0, slot1, slot2). +struct Slots { + uint8_t bytes[4 * 8 * 3]; // up to 4 channels + const uint8_t* triplet(int bit) const { return bytes + bit * 3; } +}; + +} // namespace + +// One lane, one byte 0xA5: slot0 always the mask, slot1 follows the bits MSB-first, slot2 always zero. +TEST_CASE("LCD encoder: one lane, MSB-first, 3 slots per bit") { + uint8_t wire[8 * 4] = {}; + wire[0] = 0xA5; // lane 0, channel 0: 1010 0101 + Slots s{}; + mm::encodeWs2812LcdSlots(wire, 0x01, 1, s.bytes); + + const uint8_t expectBits[8] = {1, 0, 1, 0, 0, 1, 0, 1}; + for (int bit = 0; bit < 8; bit++) { + const uint8_t* t = s.triplet(bit); + CHECK(t[0] == 0x01); // pulse start: active lanes HIGH + CHECK(t[1] == (expectBits[bit] ? 0x01 : 0x00)); // data slot + CHECK(t[2] == 0x00); // pulse tail: all LOW + } +} + +// Two lanes 0xFF/0x00 in one row: the data slot carries lane 0's bit only — the transpose itself. +TEST_CASE("LCD encoder: transpose across two lanes") { + uint8_t wire[8 * 4] = {}; + wire[0 * 4 + 0] = 0xFF; // lane 0: all ones + wire[1 * 4 + 0] = 0x00; // lane 1: all zeros + Slots s{}; + mm::encodeWs2812LcdSlots(wire, 0x03, 1, s.bytes); + + for (int bit = 0; bit < 8; bit++) { + const uint8_t* t = s.triplet(bit); + CHECK(t[0] == 0x03); // both lanes pulse-start HIGH + CHECK(t[1] == 0x01); // only lane 0 carries a 1 + CHECK(t[2] == 0x00); + } +} + +// A lane excluded from the mask contributes to NEITHER slot 0 nor slot 1, even with garbage wire bytes — short strands idle LOW (no white flashes). +TEST_CASE("LCD encoder: inactive lanes stay LOW regardless of wire content") { + uint8_t wire[8 * 4]; + std::memset(wire, 0xFF, sizeof(wire)); // garbage everywhere + Slots s{}; + mm::encodeWs2812LcdSlots(wire, 0x01, 1, s.bytes); // only lane 0 active + + for (int bit = 0; bit < 8; bit++) { + const uint8_t* t = s.triplet(bit); + CHECK(t[0] == 0x01); // lane 1..7 absent from the pulse start + CHECK(t[1] == 0x01); // and from the data slot + CHECK(t[2] == 0x00); + } +} + +// Mask 0 (a row past every lane's strand) is a fully idle row. +TEST_CASE("LCD encoder: empty mask emits all-zero slots") { + uint8_t wire[8 * 4]; + std::memset(wire, 0xFF, sizeof(wire)); + Slots s{}; + std::memset(s.bytes, 0xEE, sizeof(s.bytes)); + mm::encodeWs2812LcdSlots(wire, 0x00, 1, s.bytes); + for (int i = 0; i < 8 * 3; i++) CHECK(s.bytes[i] == 0x00); +} + +// Channel order comes from Correction (logical red → GRB wire {0,255,0}); the encoder is order-agnostic. +TEST_CASE("LCD encoder: GRB ordering via Correction") { + mm::Correction corr; + corr.rebuild(255, mm::LightPreset::GRB); + const uint8_t rgb[3] = {255, 0, 0}; // logical red + uint8_t wire[8 * 4] = {}; + corr.apply(rgb, wire); // lane 0 wire = {0, 255, 0} + + Slots s{}; + mm::encodeWs2812LcdSlots(wire, 0x01, 3, s.bytes); + for (int bit = 0; bit < 8; bit++) { + CHECK(s.triplet(bit)[1] == 0x00); // G byte: all zero data + CHECK(s.triplet(8 + bit)[1] == 0x01); // R byte: all ones data + CHECK(s.triplet(16 + bit)[1] == 0x00); // B byte: all zero data + } +} + +// RGBW rows emit 4 channels × 8 bits × 3 slots = 96 bytes. +TEST_CASE("LCD encoder: RGBW row is 96 slot bytes") { + mm::Correction corr; + corr.rebuild(255, mm::LightPreset::GRBW); + const uint8_t rgb[3] = {10, 10, 10}; + uint8_t wire[8 * 4] = {}; + corr.apply(rgb, wire); + + uint8_t out[4 * 8 * 3]; + std::memset(out, 0xEE, sizeof(out)); + mm::encodeWs2812LcdSlots(wire, 0x01, 4, out); + // The last triplet was written (its tail slot is 0, not the 0xEE poison). + CHECK(out[4 * 8 * 3 - 1] == 0x00); +} diff --git a/test/unit/light/unit_NetworkReceiveEffect.cpp b/test/unit/light/unit_NetworkReceiveEffect.cpp new file mode 100644 index 00000000..4fd85109 --- /dev/null +++ b/test/unit/light/unit_NetworkReceiveEffect.cpp @@ -0,0 +1,214 @@ +// @module NetworkReceiveEffect +// @also NetworkSendDriver + +#include "doctest.h" +#include "light/ArtNetPacket.h" +#include "light/effects/NetworkReceiveEffect.h" +#include "light/layouts/GridLayout.h" +#include "platform/platform.h" + +#include + +// These tests pin the receive side of the shared OpDmx wire format: parser +// accept/reject, universe→buffer placement and clamping (via the public +// applyDmx test surface — no sockets needed), the staging-buffer lifecycle +// (sized off the hot path, never reallocated by loop, freed on teardown), and +// one real localhost UDP round-trip that exercises the platform bind/recvFrom +// path end to end on desktop CI. + +namespace { + +// The standard rig from the effect tests (unit_NoiseEffect.cpp shape): a grid +// layout + layer with the effect as child. 16×16 RGB = 768 bytes = universes +// {0: bytes 0..509, 1: bytes 510..767} at universe_start 0. +struct Rig { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + mm::NetworkReceiveEffect fx; + + explicit Rig(mm::lengthType w = 16, mm::lengthType h = 16) { + grid.width = w; + grid.height = h; + grid.depth = 1; + layouts.addChild(&grid); + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + layer.addChild(&fx); + fx.onBuildControls(); + layer.onBuildState(); + } +}; + +} // namespace + +// --- wire format (shared header) --------------------------------------------- + +// A packet built by the sender's builder parses back to the same universe and payload — the two sides can't drift. +TEST_CASE("ArtNet OpDmx build→parse round-trip") { + uint8_t payload[6] = {1, 2, 3, 4, 5, 6}; + uint8_t pkt[mm::ARTNET_HEADER_SIZE + 6]; + const size_t len = mm::buildArtDmxPacket(pkt, 259, 7, payload, 6); + + uint16_t universe = 0, dataLen = 0; + const uint8_t* data = nullptr; + REQUIRE(mm::parseArtDmxPacket(pkt, len, universe, data, dataLen)); + CHECK(universe == 259); + CHECK(dataLen == 6); + CHECK(std::memcmp(data, payload, 6) == 0); +} + +// Bad magic, non-OpDmx opcodes, truncated headers, and lying length fields are all rejected — the receiver drops them. +TEST_CASE("ArtNet OpDmx parse rejects malformed packets") { + uint8_t payload[3] = {9, 9, 9}; + uint8_t pkt[mm::ARTNET_HEADER_SIZE + 3]; + const size_t len = mm::buildArtDmxPacket(pkt, 0, 0, payload, 3); + + uint16_t universe = 0, dataLen = 0; + const uint8_t* data = nullptr; + + // Bad magic. + uint8_t bad[mm::ARTNET_HEADER_SIZE + 3]; + std::memcpy(bad, pkt, len); + bad[0] = 'X'; + CHECK_FALSE(mm::parseArtDmxPacket(bad, len, universe, data, dataLen)); + + // Wrong opcode (OpPoll 0x2000 instead of OpDmx 0x5000). + std::memcpy(bad, pkt, len); + bad[9] = 0x20; + CHECK_FALSE(mm::parseArtDmxPacket(bad, len, universe, data, dataLen)); + + // Truncated header. + CHECK_FALSE(mm::parseArtDmxPacket(pkt, mm::ARTNET_HEADER_SIZE - 1, universe, data, dataLen)); + + // Length field claims more data than the datagram carries. + std::memcpy(bad, pkt, len); + bad[16] = 0x01; bad[17] = 0xFE; // declares 510, datagram has 3 + CHECK_FALSE(mm::parseArtDmxPacket(bad, len, universe, data, dataLen)); +} + +// --- universe placement (applyDmx, no sockets) -------------------------------- + +// Universe universe_start lands at byte 0; the next universe lands at byte 510 — the same split the sender uses. +TEST_CASE("NetworkReceiveEffect places universes at consecutive 510-byte offsets") { + Rig r; + uint8_t u0[510], u1[258]; + std::memset(u0, 0xAA, sizeof(u0)); + std::memset(u1, 0xBB, sizeof(u1)); + + r.fx.applyDmx(0, u0, sizeof(u0)); + r.fx.applyDmx(1, u1, sizeof(u1)); + r.layer.loop(); // staging → layer buffer + + const uint8_t* buf = r.layer.buffer().data(); + CHECK(buf[0] == 0xAA); + CHECK(buf[509] == 0xAA); + CHECK(buf[510] == 0xBB); + CHECK(buf[767] == 0xBB); +} + +// The layer clears its buffer every tick; staging holds the last frame, so the lights don't strobe black between packets. +TEST_CASE("NetworkReceiveEffect holds the last frame across ticks without new packets") { + Rig r; + uint8_t u0[3] = {10, 20, 30}; + r.fx.applyDmx(0, u0, sizeof(u0)); + + r.layer.loop(); + r.layer.loop(); // no new packet — must still show the last frame + const uint8_t* buf = r.layer.buffer().data(); + CHECK(buf[0] == 10); + CHECK(buf[1] == 20); + CHECK(buf[2] == 30); +} + +// Universes below universe_start are ignored; universes relative to a non-zero start land at offset 0. +TEST_CASE("NetworkReceiveEffect respects universe_start") { + Rig r; + r.fx.universeStart = 5; + uint8_t below[3] = {1, 1, 1}; + uint8_t at[3] = {7, 8, 9}; + + r.fx.applyDmx(4, below, sizeof(below)); // below start — ignored + r.fx.applyDmx(5, at, sizeof(at)); // at start — offset 0 + r.layer.loop(); + + const uint8_t* buf = r.layer.buffer().data(); + CHECK(buf[0] == 7); + CHECK(buf[1] == 8); + CHECK(buf[2] == 9); +} + +// A payload overrunning the buffer end is clamped; a universe entirely beyond the buffer is ignored. +TEST_CASE("NetworkReceiveEffect clamps payloads to the buffer") { + Rig r; // 768 bytes + uint8_t u1[510]; + std::memset(u1, 0xCC, sizeof(u1)); + + r.fx.applyDmx(1, u1, sizeof(u1)); // offset 510 + 510 bytes > 768 → clamp to 258 + r.fx.applyDmx(2, u1, sizeof(u1)); // offset 1020 — entirely beyond → ignored + r.layer.loop(); + + const uint8_t* buf = r.layer.buffer().data(); + CHECK(buf[510] == 0xCC); + CHECK(buf[767] == 0xCC); +} + +// A 0×0×0 grid accepts packets as a clean no-op — degraded, not crashed. +TEST_CASE("NetworkReceiveEffect tolerates a zero-light grid") { + Rig r(0, 0); + uint8_t u0[3] = {1, 2, 3}; + r.fx.applyDmx(0, u0, sizeof(u0)); + r.layer.loop(); + CHECK(true); // reaching here without a crash is the assertion +} + +// --- staging lifecycle --------------------------------------------------------- + +// Staging is sized in onBuildState (off the hot path), loop() never reallocates it, teardown frees it. +TEST_CASE("NetworkReceiveEffect staging buffer lifecycle") { + Rig r; + REQUIRE(r.fx.stagingData() != nullptr); + CHECK(r.fx.stagingBytes() == r.layer.buffer().bytes()); + + const uint8_t* before = r.fx.stagingData(); + r.layer.loop(); + CHECK(r.fx.stagingData() == before); // no realloc in the hot path + + r.fx.teardown(); + CHECK(r.fx.stagingData() == nullptr); + CHECK(r.fx.stagingBytes() == 0); +} + +// --- localhost round-trip (real UDP through the platform bind/recvFrom path) --- + +// A real packet sent over localhost UDP lands in the layer buffer — the end-to-end proof of the platform receive path. +TEST_CASE("NetworkReceiveEffect receives over localhost UDP") { + Rig r; + // The effect binds the three well-known protocol ports (constants by + // design). A running projectMM desktop app would hold them — don't run the + // app and ctest at once; CI runners have the ports free. + r.fx.setup(); + REQUIRE(r.fx.status() == nullptr); // binds succeeded + + mm::platform::UdpSocket tx; + REQUIRE(tx.open()); + REQUIRE(tx.connect("127.0.0.1", mm::ARTNET_PORT)); + uint8_t payload[3] = {42, 43, 44}; + uint8_t pkt[mm::ARTNET_HEADER_SIZE + 3]; + const size_t len = mm::buildArtDmxPacket(pkt, 0, 0, payload, 3); + REQUIRE(tx.sendTo(pkt, len)); + + // UDP on loopback is reliable but asynchronous — poll the frame loop with a + // bounded retry (≤100 ms) so CI stays deterministic. + bool landed = false; + for (int i = 0; i < 100 && !landed; i++) { + r.layer.loop(); + const uint8_t* buf = r.layer.buffer().data(); + landed = buf[0] == 42 && buf[1] == 43 && buf[2] == 44; + if (!landed) mm::platform::delayMs(1); + } + CHECK(landed); + + tx.close(); + r.fx.teardown(); +} diff --git a/test/unit/light/unit_NetworkReceiveEffect_protocols.cpp b/test/unit/light/unit_NetworkReceiveEffect_protocols.cpp new file mode 100644 index 00000000..84362993 --- /dev/null +++ b/test/unit/light/unit_NetworkReceiveEffect_protocols.cpp @@ -0,0 +1,287 @@ +// @module NetworkReceiveEffect +// @also NetworkSendDriver + +#include "doctest.h" +#include "light/ArtNetPacket.h" +#include "light/DdpPacket.h" +#include "light/E131Packet.h" +#include "light/effects/NetworkReceiveEffect.h" +#include "light/layouts/GridLayout.h" +#include "platform/platform.h" + +#include + +// These tests pin the E1.31 and DDP halves of the multi-protocol receive: each +// wire format round-trips build→parse, malformed and cross-protocol datagrams +// are rejected, DDP's byte-direct placement clamps safely, ArtPoll is +// recognised, and one real localhost round-trip drives all three protocol +// sockets at once. (The ArtNet half is pinned by unit_NetworkReceiveEffect.cpp.) + +namespace { + +void cidFill(uint8_t cid[mm::E131_CID_LENGTH]) { + for (uint8_t i = 0; i < mm::E131_CID_LENGTH; i++) cid[i] = static_cast(i + 1); +} + +struct Rig { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + mm::NetworkReceiveEffect fx; + + explicit Rig(mm::lengthType w = 16, mm::lengthType h = 16) { + grid.width = w; + grid.height = h; + grid.depth = 1; + layouts.addChild(&grid); + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + layer.addChild(&fx); + fx.onBuildControls(); + layer.onBuildState(); + } +}; + +} // namespace + +// --- E1.31 wire format --------------------------------------------------------- + +// A packet built by the sender's builder parses back to the same universe and payload — the two sides can't drift. +TEST_CASE("E1.31 build→parse round-trip") { + uint8_t cid[mm::E131_CID_LENGTH]; + cidFill(cid); + uint8_t payload[6] = {1, 2, 3, 4, 5, 6}; + uint8_t pkt[mm::E131_HEADER_SIZE + 6]; + const size_t len = mm::buildE131Packet(pkt, 42, 7, cid, payload, 6); + REQUIRE(len == mm::E131_HEADER_SIZE + 6); + + uint16_t universe = 0, dataLen = 0; + const uint8_t* data = nullptr; + REQUIRE(mm::parseE131Packet(pkt, len, universe, data, dataLen)); + CHECK(universe == 42); + CHECK(dataLen == 6); + CHECK(std::memcmp(data, payload, 6) == 0); +} + +// Truncated headers, a bad ACN identifier, wrong layer vectors, a non-zero start code, and a lying property count are all rejected. +TEST_CASE("E1.31 parse rejects malformed packets") { + uint8_t cid[mm::E131_CID_LENGTH]; + cidFill(cid); + uint8_t payload[3] = {9, 9, 9}; + uint8_t pkt[mm::E131_HEADER_SIZE + 3]; + const size_t len = mm::buildE131Packet(pkt, 1, 0, cid, payload, 3); + + uint16_t universe = 0, dataLen = 0; + const uint8_t* data = nullptr; + uint8_t bad[mm::E131_HEADER_SIZE + 3]; + + CHECK_FALSE(mm::parseE131Packet(pkt, mm::E131_HEADER_SIZE - 1, universe, data, dataLen)); + + std::memcpy(bad, pkt, len); + bad[4] = 'X'; // ACN identifier broken + CHECK_FALSE(mm::parseE131Packet(bad, len, universe, data, dataLen)); + + std::memcpy(bad, pkt, len); + bad[21] = 0x08; // root vector not E131 data + CHECK_FALSE(mm::parseE131Packet(bad, len, universe, data, dataLen)); + + std::memcpy(bad, pkt, len); + bad[117] = 0x03; // DMP vector wrong + CHECK_FALSE(mm::parseE131Packet(bad, len, universe, data, dataLen)); + + std::memcpy(bad, pkt, len); + bad[125] = 0x01; // non-zero start code: not light data + CHECK_FALSE(mm::parseE131Packet(bad, len, universe, data, dataLen)); + + std::memcpy(bad, pkt, len); + bad[123] = 0x02; bad[124] = 0x00; // property count claims 511 channels + CHECK_FALSE(mm::parseE131Packet(bad, len, universe, data, dataLen)); +} + +// --- DDP wire format ------------------------------------------------------------- + +// A packet built by the sender's builder parses back to the same byte offset and payload. +TEST_CASE("DDP build→parse round-trip") { + uint8_t payload[6] = {10, 20, 30, 40, 50, 60}; + uint8_t pkt[mm::DDP_HEADER_SIZE + 6]; + const size_t len = mm::buildDdpPacket(pkt, 1440, /*push=*/true, payload, 6); + REQUIRE(len == mm::DDP_HEADER_SIZE + 6); + CHECK((pkt[0] & 0x01) == 0x01); // push flag on the last packet + + uint32_t offset = 0; + uint16_t dataLen = 0; + const uint8_t* data = nullptr; + REQUIRE(mm::parseDdpPacket(pkt, len, offset, data, dataLen)); + CHECK(offset == 1440); + CHECK(dataLen == 6); + CHECK(std::memcmp(data, payload, 6) == 0); +} + +// Truncated headers, wrong version bits, and a lying length field are rejected. +TEST_CASE("DDP parse rejects malformed packets") { + uint8_t payload[3] = {1, 2, 3}; + uint8_t pkt[mm::DDP_HEADER_SIZE + 3]; + const size_t len = mm::buildDdpPacket(pkt, 0, false, payload, 3); + + uint32_t offset = 0; + uint16_t dataLen = 0; + const uint8_t* data = nullptr; + uint8_t bad[mm::DDP_HEADER_SIZE + 3]; + + CHECK_FALSE(mm::parseDdpPacket(pkt, mm::DDP_HEADER_SIZE - 1, offset, data, dataLen)); + + std::memcpy(bad, pkt, len); + bad[0] = 0x80; // version bits not 01 + CHECK_FALSE(mm::parseDdpPacket(bad, len, offset, data, dataLen)); + + std::memcpy(bad, pkt, len); + bad[8] = 0x05; bad[9] = 0x00; // declares 1280 bytes, datagram has 3 + CHECK_FALSE(mm::parseDdpPacket(bad, len, offset, data, dataLen)); +} + +// --- cross-protocol rejects ------------------------------------------------------- + +// Each universe-protocol parser refuses the other protocols' datagrams — port mix-ups degrade to silence, not garbage. +TEST_CASE("cross-protocol datagrams are rejected") { + uint8_t cid[mm::E131_CID_LENGTH]; + cidFill(cid); + uint8_t payload[3] = {1, 2, 3}; + uint8_t art[mm::ARTNET_HEADER_SIZE + 3]; + uint8_t e131[mm::E131_HEADER_SIZE + 3]; + uint8_t ddp[mm::DDP_HEADER_SIZE + 3]; + const size_t artLen = mm::buildArtDmxPacket(art, 0, 0, payload, 3); + const size_t e131Len = mm::buildE131Packet(e131, 1, 0, cid, payload, 3); + const size_t ddpLen = mm::buildDdpPacket(ddp, 0, false, payload, 3); + + uint16_t universe = 0, dataLen = 0; + uint32_t offset = 0; + const uint8_t* data = nullptr; + CHECK_FALSE(mm::parseArtDmxPacket(e131, e131Len, universe, data, dataLen)); + CHECK_FALSE(mm::parseArtDmxPacket(ddp, ddpLen, universe, data, dataLen)); + CHECK_FALSE(mm::parseE131Packet(art, artLen, universe, data, dataLen)); + CHECK_FALSE(mm::parseE131Packet(ddp, ddpLen, universe, data, dataLen)); + CHECK_FALSE(mm::parseDdpPacket(e131, e131Len, offset, data, dataLen)); + // An ArtNet datagram CAN slip past DDP's thin 2-bit version check when its + // payload is large ('A' = 0x41 has the right version bits) — the dedicated + // port, not the header, is DDP's real discriminator, and the garbage + // offset it yields is absorbed by applyBytes' bound check. With a small + // payload the lying length still rejects it: + CHECK_FALSE(mm::parseDdpPacket(art, artLen, offset, data, dataLen)); +} + +// --- ArtPoll recognition ----------------------------------------------------------- + +// An ArtPoll datagram is recognised (the discovery hook Resolume/Madrix use); OpDmx and non-ArtNet packets are not polls. +TEST_CASE("isArtPoll recognises polls and nothing else") { + uint8_t poll[14] = {'A', 'r', 't', '-', 'N', 'e', 't', 0, 0x00, 0x20, 0, 14, 0, 0}; + CHECK(mm::isArtPoll(poll, sizeof(poll))); + CHECK_FALSE(mm::isArtPoll(poll, 10)); // truncated + + uint8_t payload[3] = {1, 2, 3}; + uint8_t art[mm::ARTNET_HEADER_SIZE + 3]; + const size_t artLen = mm::buildArtDmxPacket(art, 0, 0, payload, 3); + CHECK_FALSE(mm::isArtPoll(art, artLen)); // OpDmx is not a poll +} + +// The ArtPollReply carries the fields controllers read: opcode, IP, port, names, universe switches, MAC. +TEST_CASE("buildArtPollReply lays out the reply controllers parse") { + const uint8_t ip[4] = {192, 168, 1, 230}; + const uint8_t mac[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + uint8_t reply[mm::ARTNET_POLL_REPLY_SIZE]; + const size_t len = mm::buildArtPollReply(reply, ip, mac, "projectMM", "projectMM node", 0x0123); + + CHECK(len == mm::ARTNET_POLL_REPLY_SIZE); + CHECK(std::memcmp(reply, "Art-Net", 8) == 0); + CHECK(reply[8] == 0x00); + CHECK(reply[9] == 0x21); // OpPollReply LE + CHECK(std::memcmp(reply + 10, ip, 4) == 0); + CHECK(reply[14] == 0x36); + CHECK(reply[15] == 0x19); // port 6454 LE + CHECK(reply[18] == 0x01); // NetSwitch = universe bits 14-8 + CHECK(reply[19] == 0x02); // SubSwitch = bits 7-4 + CHECK(reply[190] == 0x03); // SwOut[0] = bits 3-0 + CHECK(std::strcmp(reinterpret_cast(reply + 26), "projectMM") == 0); + CHECK(std::strcmp(reinterpret_cast(reply + 44), "projectMM node") == 0); + CHECK(std::memcmp(reply + 201, mac, 6) == 0); +} + +// --- placement (applyBytes / per-protocol applyDmx) --------------------------------- + +// DDP's byte addressing lands payloads at the exact offset; out-of-range and overflowing offsets are clamped or dropped. +TEST_CASE("applyBytes places, clamps, and survives hostile offsets") { + Rig r; // 768-byte staging + uint8_t solid[16]; + std::memset(solid, 0xAB, sizeof(solid)); + + r.fx.applyBytes(100, solid, sizeof(solid)); + CHECK(r.fx.stagingData()[100] == 0xAB); + CHECK(r.fx.stagingData()[115] == 0xAB); + + r.fx.applyBytes(760, solid, sizeof(solid)); // 8 bytes fit, 8 clamp + CHECK(r.fx.stagingData()[767] == 0xAB); + + r.fx.applyBytes(768, solid, sizeof(solid)); // exactly past the end — dropped + r.fx.applyBytes(0xFFFFFFF0u, solid, sizeof(solid)); // hostile: must not overflow + CHECK(true); // reaching here without ASAN findings is the assertion +} + +// channels_per_universe = 512 maps universes at 512-byte strides and clamps a 512-channel payload to its slot. +TEST_CASE("applyDmx honours channels_per_universe") { + Rig r; + r.fx.channelsPerUniverse = 512; + uint8_t u0[512], u1[3] = {7, 8, 9}; + std::memset(u0, 0xCD, sizeof(u0)); + + r.fx.applyDmx(0, u0, sizeof(u0)); + r.fx.applyDmx(1, u1, sizeof(u1)); + CHECK(r.fx.stagingData()[511] == 0xCD); + CHECK(r.fx.stagingData()[512] == 7); // universe 1 starts at byte 512, not 510 + + // A 512-byte payload with stride 510 clamps to 510 — the 2 padding bytes + // cannot bleed into the next universe's slot. + r.fx.channelsPerUniverse = 510; + std::memset(const_cast(r.fx.stagingData()), 0, r.fx.stagingBytes()); + r.fx.applyDmx(0, u0, sizeof(u0)); + CHECK(r.fx.stagingData()[509] == 0xCD); + CHECK(r.fx.stagingData()[510] == 0); +} + +// --- localhost round-trip: all three protocols into one effect ------------------------ + +// Three senders — one per protocol — hit the same effect on its three ports; each payload lands. The autodetect proof. +TEST_CASE("NetworkReceiveEffect receives all three protocols at once over localhost") { + Rig r; + r.fx.setup(); + REQUIRE(r.fx.status() == nullptr); // all three binds succeeded + + uint8_t cid[mm::E131_CID_LENGTH]; + cidFill(cid); + + mm::platform::UdpSocket artTx, e131Tx, ddpTx; + REQUIRE((artTx.open() && artTx.connect("127.0.0.1", mm::ARTNET_PORT))); + REQUIRE((e131Tx.open() && e131Tx.connect("127.0.0.1", mm::E131_PORT))); + REQUIRE((ddpTx.open() && ddpTx.connect("127.0.0.1", mm::DDP_PORT))); + + // Three distinct payloads at three distinct buffer positions: ArtNet → + // universe 0 (offset 0), E1.31 → universe 1 (offset 510), DDP → byte 600. + uint8_t a[3] = {11, 12, 13}, e[3] = {21, 22, 23}, d[3] = {31, 32, 33}; + uint8_t pkt[mm::E131_HEADER_SIZE + 3]; + artTx.sendTo(pkt, mm::buildArtDmxPacket(pkt, 0, 0, a, 3)); + e131Tx.sendTo(pkt, mm::buildE131Packet(pkt, 1, 0, cid, e, 3)); + ddpTx.sendTo(pkt, mm::buildDdpPacket(pkt, 600, true, d, 3)); + + bool landed = false; + for (int i = 0; i < 100 && !landed; i++) { + r.layer.loop(); + const uint8_t* buf = r.layer.buffer().data(); + landed = buf[0] == 11 && buf[510] == 21 && buf[600] == 31; + if (!landed) mm::platform::delayMs(1); + } + CHECK(landed); + CHECK(r.fx.status() != nullptr); // "receiving " diagnostic is set + + artTx.close(); + e131Tx.close(); + ddpTx.close(); + r.fx.teardown(); +} diff --git a/test/unit/light/unit_ArtNetSendDriver_no_alloc_in_loop.cpp b/test/unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp similarity index 72% rename from test/unit/light/unit_ArtNetSendDriver_no_alloc_in_loop.cpp rename to test/unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp index 6d5b6488..1440f682 100644 --- a/test/unit/light/unit_ArtNetSendDriver_no_alloc_in_loop.cpp +++ b/test/unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp @@ -1,7 +1,7 @@ -// @module ArtNetSendDriver +// @module NetworkSendDriver // @also Drivers, Correction -// Pins the no-allocation-in-loop contract for ArtNetSendDriver. The framework +// Pins the no-allocation-in-loop contract for NetworkSendDriver. The framework // fires onBuildState (topology) and onCorrectionChanged (preset toggle) off // the hot path; loop() must read the pre-sized buffer and never allocate. // @@ -11,7 +11,7 @@ // (the resize fired before any loop()) and that the buffer matches the source. #include "doctest.h" -#include "light/drivers/ArtNetSendDriver.h" +#include "light/drivers/NetworkSendDriver.h" #include "light/drivers/Correction.h" #include "light/drivers/Drivers.h" #include "light/layers/Buffer.h" @@ -19,14 +19,14 @@ // onBuildState sizes the correction-applied buffer to source-count × out-channels. // The size matches what loop() needs on its first send. Calling loop() // after onBuildState must not reallocate — pin the data pointer + shape. -TEST_CASE("ArtNetSendDriver sizes corrected_ in onBuildState, not in loop") { +TEST_CASE("NetworkSendDriver sizes corrected_ in onBuildState, not in loop") { mm::Buffer source; REQUIRE(source.allocate(64, 3)); mm::Correction correction; correction.rebuild(255, mm::LightPreset::RGB); - mm::ArtNetSendDriver driver; + mm::NetworkSendDriver driver; driver.setSourceBuffer(&source); driver.setCorrection(&correction); driver.onBuildState(); @@ -36,24 +36,32 @@ TEST_CASE("ArtNetSendDriver sizes corrected_ in onBuildState, not in loop") { CHECK(driver.correctedBuffer().count() == 64); CHECK(driver.correctedBuffer().channelsPerLight() == 3); - // loop() must not reallocate — same backing pointer, same shape. + // loop() must not reallocate — same backing pointer, same shape — on every + // protocol path (ArtNet, E1.31, DDP all share the pre-sized buffer and a + // stack packet). Virtual time advances past the fps limiter between + // protocols so each send path actually executes. const uint8_t* dataBefore = driver.correctedBuffer().data(); - driver.loop(); - CHECK(driver.correctedBuffer().data() == dataBefore); - CHECK(driver.correctedBuffer().count() == 64); - CHECK(driver.correctedBuffer().channelsPerLight() == 3); + for (uint8_t p = 0; p < mm::NetworkSendDriver::kProtocolCount; p++) { + mm::platform::setTestNowMs(1000u + 100u * p); + driver.protocol = p; + driver.loop(); + CHECK(driver.correctedBuffer().data() == dataBefore); + CHECK(driver.correctedBuffer().count() == 64); + CHECK(driver.correctedBuffer().channelsPerLight() == 3); + } + mm::platform::setTestNowMs(0); // restore real-clock behaviour for later cases } // A preset toggle from RGB to RGBW grows outChannels from 3 to 4. The grow // runs in onCorrectionChanged, off the hot path. -TEST_CASE("ArtNetSendDriver grows corrected_ in onCorrectionChanged on RGB → RGBW") { +TEST_CASE("NetworkSendDriver grows corrected_ in onCorrectionChanged on RGB → RGBW") { mm::Buffer source; REQUIRE(source.allocate(32, 3)); mm::Correction correction; correction.rebuild(255, mm::LightPreset::RGB); - mm::ArtNetSendDriver driver; + mm::NetworkSendDriver driver; driver.setSourceBuffer(&source); driver.setCorrection(&correction); driver.onBuildState(); @@ -70,14 +78,14 @@ TEST_CASE("ArtNetSendDriver grows corrected_ in onCorrectionChanged on RGB → R // A brightness-only change keeps outChannels at 3 — onCorrectionChanged is // still called, but the resize short-circuits (existing buffer already fits). -TEST_CASE("ArtNetSendDriver onCorrectionChanged is a no-op when outChannels unchanged") { +TEST_CASE("NetworkSendDriver onCorrectionChanged is a no-op when outChannels unchanged") { mm::Buffer source; REQUIRE(source.allocate(48, 3)); mm::Correction correction; correction.rebuild(255, mm::LightPreset::RGB); - mm::ArtNetSendDriver driver; + mm::NetworkSendDriver driver; driver.setSourceBuffer(&source); driver.setCorrection(&correction); driver.onBuildState(); diff --git a/test/unit/light/unit_NetworkSendDriver_packet.cpp b/test/unit/light/unit_NetworkSendDriver_packet.cpp new file mode 100644 index 00000000..c066d675 --- /dev/null +++ b/test/unit/light/unit_NetworkSendDriver_packet.cpp @@ -0,0 +1,146 @@ +// @module NetworkSendDriver + +#include "doctest.h" +#include "light/drivers/NetworkSendDriver.h" + +#include + +// The built packet contains the exact header layout the Art-Net spec mandates: ID, OpCode, version, sequence, physical, universe, length, data. +TEST_CASE("ArtNet packet header format") { + uint8_t data[3] = {255, 0, 128}; + uint8_t packet[mm::ARTNET_HEADER_SIZE + 3]; + + size_t len = mm::buildArtDmxPacket(packet, 0, 42, data, 3); + + CHECK(len == mm::ARTNET_HEADER_SIZE + 3); + + // "Art-Net\0" at offset 0 + CHECK(std::memcmp(packet, "Art-Net", 8) == 0); + + // OpCode: 0x5000 little-endian at offset 8 + CHECK(packet[8] == 0x00); + CHECK(packet[9] == 0x50); + + // Protocol version: 14 big-endian at offset 10 + CHECK(packet[10] == 0x00); + CHECK(packet[11] == 0x0e); + + // Sequence at offset 12 + CHECK(packet[12] == 42); + + // Physical at offset 13 + CHECK(packet[13] == 0); + + // Universe: 0 little-endian at offset 14 + CHECK(packet[14] == 0x00); + CHECK(packet[15] == 0x00); + + // Length: 3 big-endian at offset 16 + CHECK(packet[16] == 0x00); + CHECK(packet[17] == 0x03); + + // Data at offset 18 + CHECK(packet[18] == 255); + CHECK(packet[19] == 0); + CHECK(packet[20] == 128); +} + +// Universe 259 (0x0103) is encoded little-endian (low byte first), matching the Art-Net wire format. +TEST_CASE("ArtNet packet with non-zero universe") { + uint8_t data[6] = {1, 2, 3, 4, 5, 6}; + uint8_t packet[mm::ARTNET_HEADER_SIZE + 6]; + + mm::buildArtDmxPacket(packet, 259, 0, data, 6); + + // Universe 259 = 0x0103, little-endian + CHECK(packet[14] == 0x03); + CHECK(packet[15] == 0x01); +} + +// 256 RGB lights (768 bytes) split across exactly 2 universes (510 + 258), matching the 510-channel-per-universe cap. +TEST_CASE("ArtNet universe splitting for 256 RGB lights") { + // 256 RGB lights = 768 bytes = 2 universes (510 + 258) + constexpr size_t maxPerUniverse = mm::MAX_CHANNELS_PER_UNIVERSE; + constexpr size_t totalBytes = 256 * 3; + + size_t universeCount = 0; + size_t sent = 0; + while (sent < totalBytes) { + size_t chunk = totalBytes - sent; + if (chunk > maxPerUniverse) chunk = maxPerUniverse; + sent += chunk; + universeCount++; + } + + CHECK(universeCount == 2); + CHECK(sent == totalBytes); +} + +// The data-length field is encoded big-endian (high byte first), unlike the universe field — matching the Art-Net spec. +TEST_CASE("ArtNet packet length field is big-endian") { + uint8_t data[510]; + std::memset(data, 0, sizeof(data)); + uint8_t packet[mm::ARTNET_HEADER_SIZE + 510]; + + mm::buildArtDmxPacket(packet, 0, 0, data, 510); + + // 510 = 0x01FE, big-endian + CHECK(packet[16] == 0x01); + CHECK(packet[17] == 0xFE); +} + +// The built E1.31 packet carries the exact ACN layout strict sACN receivers (and tools like xLights) validate: identifier, the three flags+length fields, CID, source name, priority, universe, property count, start code. +TEST_CASE("E1.31 packet header format") { + uint8_t cid[mm::E131_CID_LENGTH]; + for (uint8_t i = 0; i < mm::E131_CID_LENGTH; i++) cid[i] = static_cast(0xC0 + i); + uint8_t data[3] = {255, 0, 128}; + uint8_t pkt[mm::E131_HEADER_SIZE + 3]; + + const size_t len = mm::buildE131Packet(pkt, 0x0103, 42, cid, data, 3); + REQUIRE(len == mm::E131_HEADER_SIZE + 3); // totalLen = 129 + + // Root layer: preamble, ACN identifier, flags+length (0x7000 | 129-16), vector, CID. + CHECK(pkt[0] == 0x00); CHECK(pkt[1] == 0x10); + CHECK(std::memcmp(pkt + 4, "ASC-E1.17\0\0\0", 12) == 0); + CHECK(pkt[16] == 0x70); CHECK(pkt[17] == 113); + CHECK(pkt[21] == 0x04); + CHECK(std::memcmp(pkt + 22, cid, mm::E131_CID_LENGTH) == 0); + + // Framing layer: flags+length (129-38=91), vector, source name, priority 100, + // sequence, universe big-endian. + CHECK(pkt[38] == 0x70); CHECK(pkt[39] == 91); + CHECK(pkt[43] == 0x02); + CHECK(std::strcmp(reinterpret_cast(pkt + 44), "projectMM") == 0); + CHECK(pkt[108] == 100); + CHECK(pkt[111] == 42); + CHECK(pkt[113] == 0x01); CHECK(pkt[114] == 0x03); + + // DMP layer: flags+length (129-115=14), vector, address/data type, increment, + // property count = 1 + 3, start code 0; then the data. + CHECK(pkt[115] == 0x70); CHECK(pkt[116] == 14); + CHECK(pkt[117] == 0x02); + CHECK(pkt[118] == 0xA1); + CHECK(pkt[122] == 0x01); + CHECK(pkt[123] == 0x00); CHECK(pkt[124] == 4); + CHECK(pkt[125] == 0x00); + CHECK(pkt[126] == 255); CHECK(pkt[127] == 0); CHECK(pkt[128] == 128); +} + +// The built DDP packet carries version+push bits, RGB data type, default destination, and big-endian offset/length. +TEST_CASE("DDP packet header format") { + uint8_t data[3] = {255, 0, 128}; + uint8_t pkt[mm::DDP_HEADER_SIZE + 3]; + + const size_t len = mm::buildDdpPacket(pkt, 0x01020304u, /*push=*/false, data, 3); + REQUIRE(len == mm::DDP_HEADER_SIZE + 3); + CHECK(pkt[0] == 0x40); // version 01, push clear + CHECK(pkt[2] == 0x01); // RGB + CHECK(pkt[3] == 0x01); // default display + CHECK(pkt[4] == 0x01); CHECK(pkt[5] == 0x02); + CHECK(pkt[6] == 0x03); CHECK(pkt[7] == 0x04); // offset big-endian + CHECK(pkt[8] == 0x00); CHECK(pkt[9] == 0x03); // length big-endian + CHECK(pkt[10] == 255); + + mm::buildDdpPacket(pkt, 0, /*push=*/true, data, 3); + CHECK(pkt[0] == 0x41); // push set on the frame's last packet +} diff --git a/test/unit/light/unit_ParlioLedDriver.cpp b/test/unit/light/unit_ParlioLedDriver.cpp new file mode 100644 index 00000000..d55f9685 --- /dev/null +++ b/test/unit/light/unit_ParlioLedDriver.cpp @@ -0,0 +1,242 @@ +// @module ParlioLedDriver +// @also Drivers, Correction + +#include "doctest.h" +#include "light/drivers/Correction.h" +#include "light/drivers/ParlioLedDriver.h" +#include "light/layers/Buffer.h" + +#include + +// Host-side half of the Parlio driver: lane slicing (the shared PinList +// semantics), the frame-byte arithmetic (latch pad, 64-byte alignment, RGBW +// growth), and the parse-error/recovery shape. The hardware half (TX unit init, +// DMA transmit) is inert on the host — desktop stubs return false/nullptr — and +// is proven on the P4. The encoder itself is shared with the LCD driver and is +// covered by unit_LcdLedEncoder.cpp, so it isn't re-tested here. +// +// The one behavioural difference from the LCD driver pinned below: Parlio has +// NO exactly-8-pins rule — 1..8 lanes are all valid (it takes the data GPIOs +// directly, no all-lanes-required i80 bus). + +namespace { + +void wire(mm::ParlioLedDriver& d, mm::Buffer& src, mm::Correction& corr, + mm::nrOfLightsType lights) { + // Pins default to UNSET now (the "default only when it cannot do harm" rule — + // the user solders the strand to its own GPIOs), so a fresh driver idles until + // configured. These slicing/frame tests exercise the lane logic, not the + // default value, so the helper supplies the bench 8-pin set unless a case set + // its own pins first. + if (d.pins[0] == '\0') std::strcpy(d.pins, "20,21,22,23,24,25,26,27"); + // allocate succeeds exactly when lights > 0 (zero-grid wires an empty buffer + // on purpose); a masked alloc failure would fail cases downstream. + REQUIRE(src.allocate(lights, 3) == (lights > 0)); + corr.rebuild(255, mm::LightPreset::GRB); // 3 out-channels + d.onBuildControls(); + d.setSourceBuffer(&src); + d.setCorrection(&corr); + d.onBuildState(); +} + +// frameBytes = maxLaneLights × outCh × 24 + 864 latch pad, rounded up to 64. +size_t expectFrame(mm::nrOfLightsType maxLights, uint8_t outCh) { + if (maxLights == 0) return 0; + const size_t raw = static_cast(maxLights) * outCh * 24 + 800 + 64; + return (raw + 63) & ~static_cast(63); +} + +} // namespace + +// Three lanes (Parlio accepts any 1..8 count) slice the buffer consecutively; +// the frame is sized by the LONGEST lane. +TEST_CASE("ParlioLedDriver slices lanes and sizes the frame by the longest") { + mm::ParlioLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.pins, "36,37,38"); + std::strcpy(d.ledsPerPin, "50,20,20"); + wire(d, src, corr, 90); + + REQUIRE(d.laneCount() == 3); + CHECK(d.laneLightCount(0) == 50); + CHECK(d.laneLightCount(1) == 20); + CHECK(d.laneLightCount(2) == 20); + CHECK(d.laneStart(0) == 0); + CHECK(d.laneStart(1) == 50); + CHECK(d.laneStart(2) == 70); + CHECK(d.maxLaneLights() == 50); + CHECK(d.frameBytes() == expectFrame(50, 3)); +} + +// Empty ledsPerPin (the default) splits evenly over the 8 lanes — shared PinList +// semantics, same as the RMT/LCD drivers. +TEST_CASE("ParlioLedDriver even split over 8 lanes") { + mm::ParlioLedDriver d; + mm::Buffer src; + mm::Correction corr; + wire(d, src, corr, 256); // ledsPerPin empty (default) = even split + + REQUIRE(d.laneCount() == 8); + CHECK(d.laneLightCount(0) == 32); + CHECK(d.laneLightCount(7) == 32); + CHECK(d.maxLaneLights() == 32); + CHECK(d.frameBytes() == expectFrame(32, 3)); +} + +// The Parlio-vs-LCD difference: 1..8 pins are ALL valid (no exactly-8 rule). +TEST_CASE("ParlioLedDriver accepts any lane count from 1 to 8") { + mm::Correction corr; + corr.rebuild(255, mm::LightPreset::GRB); + for (const char* pinList : {"36", "36,37", "36,37,38,39,40", "36,37,38,39,40,41,42,43"}) { + mm::ParlioLedDriver d; + mm::Buffer src; + std::strcpy(d.pins, pinList); + wire(d, src, corr, 64); + // count the commas+1 to know the expected lane count + uint8_t expected = 1; + for (const char* p = pinList; *p; p++) if (*p == ',') expected++; + CHECK(d.laneCount() == expected); + CHECK(d.status() == nullptr); // no error for any 1..8 count + } +} + +// More than 8 pins is rejected (the chip's lane cap), like the other drivers. +TEST_CASE("ParlioLedDriver rejects more than 8 pins") { + mm::ParlioLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.pins, "1,2,3,4,5,6,7,8,9"); + wire(d, src, corr, 64); + CHECK(d.laneCount() == 0); + CHECK(d.status() != nullptr); +} + +// An RGB→RGBW preset toggle grows the frame (32 vs 24 slot bytes per light). +TEST_CASE("ParlioLedDriver frame grows on RGBW preset") { + mm::ParlioLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.ledsPerPin, "50,50"); + wire(d, src, corr, 100); + CHECK(d.frameBytes() == expectFrame(50, 3)); + + corr.rebuild(255, mm::LightPreset::GRBW); + d.onCorrectionChanged(); + CHECK(d.frameBytes() == expectFrame(50, 4)); +} + +// A bad pin list idles the driver with the parse literal in the status; fixing it recovers. +TEST_CASE("ParlioLedDriver bad pins → status error → recovery") { + mm::ParlioLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.pins, "36,nope"); + wire(d, src, corr, 64); + + CHECK(d.laneCount() == 0); + CHECK(d.frameBytes() == 0); + CHECK(d.status() != nullptr); + + std::strcpy(d.pins, "36,37"); + d.onBuildState(); + CHECK(d.laneCount() == 2); + CHECK(d.status() == nullptr); +} + +// Pins now default UNSET (the "default only when it cannot do harm" rule — the +// strand is user-soldered). A fresh, unconfigured driver idles, never grabbing a +// GPIO. (wire() back-fills empty pins for the slicing cases, so this one wires +// the buffer directly to keep pins empty.) +TEST_CASE("ParlioLedDriver with the empty default pins idles cleanly") { + mm::ParlioLedDriver d; + mm::Buffer src; + mm::Correction corr; + REQUIRE(d.pins[0] == '\0'); // the empty default, not a bench guess + REQUIRE(src.allocate(64, 3)); + corr.rebuild(255, mm::LightPreset::GRB); + d.onBuildControls(); + d.setSourceBuffer(&src); + d.setCorrection(&corr); + d.onBuildState(); + + CHECK(d.laneCount() == 0); // no lanes claimed + CHECK(d.frameBytes() == 0); + CHECK(d.status() != nullptr); // "set pins" surfaced, not silent + d.loop(); // must be a no-op, not a crash +} + +// A 0×0×0 grid is a clean idle: zero counts, zero frame, no crash. +TEST_CASE("ParlioLedDriver tolerates a zero-light buffer") { + mm::ParlioLedDriver d; + mm::Buffer src; + mm::Correction corr; + wire(d, src, corr, 0); + + CHECK(d.laneCount() == 8); // the default 8 pins parse fine + CHECK(d.maxLaneLights() == 0); + CHECK(d.frameBytes() == 0); + d.loop(); // must be a no-op, not a crash + CHECK(true); +} + +// loop() is crash-safe across single-pin / multi-pin / pre-init configs (the +// transmit path is gated out on the host; this pins the reachable contract). +TEST_CASE("ParlioLedDriver loop is crash-safe for every pin configuration") { + mm::Correction corr; + corr.rebuild(255, mm::LightPreset::GRB); + + SUBCASE("single pin, populated grid") { + mm::ParlioLedDriver d; mm::Buffer src; + std::strcpy(d.pins, "36"); + wire(d, src, corr, 64); + d.loop(); + } + SUBCASE("multi-pin even split") { + mm::ParlioLedDriver d; mm::Buffer src; + std::strcpy(d.pins, "36,37,38"); + wire(d, src, corr, 90); + REQUIRE(d.laneCount() == 3); + d.loop(); + } + SUBCASE("loop before any buffer is wired") { + mm::ParlioLedDriver d; + d.onBuildControls(); + d.loop(); + } + CHECK(true); +} + +// setup/teardown cycles leave no residue (status clean, ASAN-checked heap). +TEST_CASE("ParlioLedDriver setup/teardown is repeatable") { + mm::ParlioLedDriver d; + mm::Buffer src; + mm::Correction corr; + src.allocate(64, 3); + corr.rebuild(255, mm::LightPreset::GRB); + std::strcpy(d.pins, "20,21,22,23,24,25,26,27"); // pins now default UNSET + d.onBuildControls(); + for (int cycle = 0; cycle < 4; cycle++) { + d.setup(); + d.setSourceBuffer(&src); + d.setCorrection(&corr); + d.onBuildState(); + REQUIRE(d.laneCount() == 8); // the 8 pins set above + d.teardown(); + CHECK(d.status() == nullptr); + } +} + +// loopbackRxPin is bound always, visible only while loopbackTest is on. +TEST_CASE("ParlioLedDriver loopbackRxPin tracks the loopbackTest toggle") { + mm::ParlioLedDriver d; + d.onBuildControls(); + bool found = false; + for (uint8_t i = 0; i < d.controls().count(); i++) { + if (std::strcmp(d.controls()[i].name, "loopbackRxPin") == 0) { + found = true; + CHECK(d.controls()[i].hidden == true); // test mode off by default + } + } + CHECK(found); +} diff --git a/test/unit/light/unit_RmtLedDriver_lifecycle.cpp b/test/unit/light/unit_RmtLedDriver_lifecycle.cpp new file mode 100644 index 00000000..0276bcf1 --- /dev/null +++ b/test/unit/light/unit_RmtLedDriver_lifecycle.cpp @@ -0,0 +1,176 @@ +// @module RmtLedDriver +// @also Drivers, Correction + +#include "doctest.h" +#include "light/drivers/RmtLedDriver.h" +#include "light/drivers/Correction.h" +#include "light/layers/Buffer.h" +#include "unit/core/conditional_controls.h" // shared conditional-control helpers + +#include // std::strcpy (writing the pins text control directly) + +// These tests pin the symbol-buffer LIFECYCLE — the exact class of bug that +// reached hardware: a review fix made deinit() free symbols_, and because +// reinit() calls deinit(), every rebuild freed the buffer loop() needs, so the +// driver silently stopped transmitting. None of that touches the RMT peripheral +// (ESP32-only); it's pure host-testable buffer ownership, which is why a unit +// test is the right guard. The symbolBuffer()/symbolCapacity() accessors are +// test-only (mirror ArtNet's correctedBuffer()). + +namespace { + +// Wire a driver up to a source buffer + correction the way the Drivers container +// does, then run onBuildState (the sizing hook). Returns nothing; the caller +// inspects the driver. +void wire(mm::RmtLedDriver& d, mm::Buffer& src, mm::Correction& corr, + mm::nrOfLightsType lights = 64) { + REQUIRE(src.allocate(lights, 3)); // a masked alloc failure would fail cases downstream + corr.rebuild(255, mm::LightPreset::GRB); // 3 out-channels + d.onBuildControls(); + d.setSourceBuffer(&src); + d.setCorrection(&corr); + d.onBuildState(); +} + +} // namespace + +TEST_CASE("RmtLedDriver sizes the symbol buffer in onBuildState") { + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + wire(d, src, corr, 64); + + // 64 lights × 3 channels × 8 bits = 1536 symbols. + REQUIRE(d.symbolBuffer() != nullptr); + CHECK(d.symbolCapacity() >= static_cast(64) * 3 * 8); +} + +TEST_CASE("RmtLedDriver keeps the symbol buffer across a rebuild (reinit must not free it)") { + // The regression: onBuildState() does resizeSymbols() THEN reinit(), and a + // bad reinit()->deinit() freed symbols_ right after it was allocated, so the + // buffer was null by the time loop() ran. A second onBuildState (what a pins + // change / topology rebuild triggers) must leave the buffer present. + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + wire(d, src, corr, 64); + REQUIRE(d.symbolBuffer() != nullptr); + + d.onBuildState(); // simulate a rebuild (the path that runs reinit()) + CHECK(d.symbolBuffer() != nullptr); // would be null with the deinit()-frees bug + CHECK(d.symbolCapacity() >= static_cast(64) * 3 * 8); +} + +TEST_CASE("RmtLedDriver keeps the symbol buffer across a pins change") { + // Same regression class as above, multi-pin flavour: editing the pins list + // triggers a rebuild that re-parses and re-inits N channels — none of which + // may free the symbol buffer loop() encodes into. + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + wire(d, src, corr, 64); + REQUIRE(d.symbolBuffer() != nullptr); + + std::strcpy(d.pins, "18,17"); + d.onBuildState(); + CHECK(d.symbolBuffer() != nullptr); + CHECK(d.pinCount() == 2); +} + +TEST_CASE("RmtLedDriver grows the symbol buffer when the grid grows") { + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + wire(d, src, corr, 16); + const size_t cap16 = d.symbolCapacity(); + CHECK(cap16 >= static_cast(16) * 3 * 8); + + // Grow the source to 256 lights and rebuild: capacity must grow to fit. + src.allocate(256, 3); + d.setSourceBuffer(&src); + d.onBuildState(); + CHECK(d.symbolBuffer() != nullptr); + CHECK(d.symbolCapacity() >= static_cast(256) * 3 * 8); +} + +TEST_CASE("RmtLedDriver releases the symbol buffer on teardown") { + // The leak CodeRabbit flagged: teardown must free the buffer (and only + // teardown — not deinit, which reinit calls). + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + wire(d, src, corr, 64); + REQUIRE(d.symbolBuffer() != nullptr); + + d.teardown(); + CHECK(d.symbolBuffer() == nullptr); + CHECK(d.symbolCapacity() == 0); +} + +// MoonModule contract: teardown reverses setup, so setup→teardown→setup→teardown +// cycles leave no residue — no leaked heap (ASAN in the test runner catches that), +// no stuck state. After each teardown the driver must look untouched: no symbol +// buffer, no status. Run several cycles to surface any accumulation. +TEST_CASE("RmtLedDriver setup/teardown is repeatable with no residual state") { + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + src.allocate(64, 3); + corr.rebuild(255, mm::LightPreset::GRB); + d.onBuildControls(); + + for (int cycle = 0; cycle < 4; cycle++) { + d.setup(); // (re)init the channel + d.setSourceBuffer(&src); // resizeSymbols allocates the buffer + d.setCorrection(&corr); + d.onBuildState(); // size buffer + reinit, as the Scheduler does + REQUIRE(d.symbolBuffer() != nullptr); + + d.teardown(); // must fully reverse the above + CHECK(d.symbolBuffer() == nullptr); // buffer freed (ASAN: no leak across cycles) + CHECK(d.symbolCapacity() == 0); + CHECK(d.status() == nullptr); // no lingering status string + } +} + +// Conditional control: loopbackRxPin is visible only while loopbackTest is on, +// hidden otherwise — but always bound (so a saved rxPin loads regardless). Same +// add-then-setHidden pattern as NetworkModule (architecture.md § Conditional +// controls). This pins the exact behavior that, with the old UI, showed the pin +// at the wrong times; a regression in the C++ flag now fails here. +TEST_CASE("RmtLedDriver loopbackRxPin tracks the loopbackTest toggle") { + mm::RmtLedDriver d; + d.onBuildControls(); + auto setTest = [&](bool on) { + mm::test::setControlValue(d, "loopbackTest", on); + }; + mm::test::checkConditionalControl(d, "loopbackRxPin", setTest, /*visibleWhenTrue=*/true); +} + +// Editing `pins` while the loopback test is ON must refresh the parsed config +// before the self-test runs — onUpdate fires before the buildState sweep re-parses, +// so without the in-branch parseConfig() the test would transmit on the OLD pin and +// show a verdict for it. Mirrors the fix in ParallelLedDriver; this pins the RMT +// sibling that the dedup left behind. Host-observable via pinCount(): the refresh +// re-parses to the new pin set even though the platform loopback itself is inert. +TEST_CASE("RmtLedDriver loopback re-parses pins before testing (no stale-pin verdict)") { + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + src.allocate(64, 3); + corr.rebuild(255, mm::LightPreset::GRB); + std::strcpy(d.pins, "18"); + d.onBuildControls(); + d.setSourceBuffer(&src); + d.setCorrection(&corr); + d.onBuildState(); + REQUIRE(d.pinCount() == 1); + + // Turn the test on, then edit pins to a 3-pin set and fire the pin update the + // way the control framework does (write the buffer, then onUpdate). The config + // must already reflect 3 pins by the time the self-test reads pinList_. + mm::test::setControlValue(d, "loopbackTest", true); + std::strcpy(d.pins, "18,17,16"); + d.onUpdate("pins"); + CHECK(d.pinCount() == 3); // refreshed before the test, not the stale 1 +} diff --git a/test/unit/light/unit_RmtLedDriver_pins.cpp b/test/unit/light/unit_RmtLedDriver_pins.cpp new file mode 100644 index 00000000..5167d019 --- /dev/null +++ b/test/unit/light/unit_RmtLedDriver_pins.cpp @@ -0,0 +1,284 @@ +// @module RmtLedDriver +// @also Drivers, Correction + +#include "doctest.h" +#include "light/drivers/RmtLedDriver.h" +#include "light/drivers/Correction.h" +#include "light/layers/Buffer.h" + +#include + +// These tests pin the MULTI-PIN surface: the `pins` / `ledsPerPin` text-control +// parsing (shared free functions in PinList.h, used by RmtLedDriver and LcdLedDriver +// precedent) and the slice arithmetic down to per-pin symbol offsets. All pure +// host logic — the RMT peripheral is never touched; on desktop the channel init +// is inert but parsing and slicing must behave identically, which is exactly +// what lets CI guard them. + +namespace { + +void wire(mm::RmtLedDriver& d, mm::Buffer& src, mm::Correction& corr, + mm::nrOfLightsType lights) { + REQUIRE(src.allocate(lights, 3)); // a masked alloc failure would fail cases downstream + corr.rebuild(255, mm::LightPreset::GRB); // 3 out-channels + d.onBuildControls(); + d.setSourceBuffer(&src); + d.setCorrection(&corr); + d.onBuildState(); +} + +} // namespace + +// --- parsePinList ----------------------------------------------------------- + +// "18,17,16" parses to three pins in list order — the order defines the buffer slices. +TEST_CASE("parsePinList accepts a comma-separated list, in order") { + uint16_t pins[8] = {}; + uint8_t n = 0; + CHECK(mm::parsePinList("18,17,16", pins, 8, n) == nullptr); + REQUIRE(n == 3); + CHECK(pins[0] == 18); + CHECK(pins[1] == 17); + CHECK(pins[2] == 16); +} + +// A single pin (the default "18") and spaces around tokens are both fine. +TEST_CASE("parsePinList accepts a single pin and spaces around tokens") { + uint16_t pins[8] = {}; + uint8_t n = 0; + CHECK(mm::parsePinList("18", pins, 8, n) == nullptr); + REQUIRE(n == 1); + CHECK(pins[0] == 18); + + CHECK(mm::parsePinList(" 18, 17 ", pins, 8, n) == nullptr); + REQUIRE(n == 2); + CHECK(pins[1] == 17); +} + +TEST_CASE("parsePinList rejects bad input with a static error message") { + uint16_t pins[8] = {}; + uint8_t n = 0; + // Bad token, trailing comma (empty token), and the empty string are all + // invalid — the driver idles with the message in its status field. + CHECK(mm::parsePinList("18,x", pins, 8, n) != nullptr); + CHECK(mm::parsePinList("18,", pins, 8, n) != nullptr); + CHECK(mm::parsePinList("", pins, 8, n) != nullptr); +} + +// maxPins is the chip's RMT TX-channel cap: 5 pins fail an S3-sized 4, fit a classic 8. +TEST_CASE("parsePinList enforces maxPins (the chip's TX-channel cap)") { + uint16_t pins[8] = {}; + uint8_t n = 0; + // 5 pins through an S3-sized cap of 4 → rejected. + CHECK(mm::parsePinList("1,2,3,4,5", pins, 4, n) != nullptr); + // The same list fits the classic-ESP32 cap of 8. + CHECK(mm::parsePinList("1,2,3,4,5", pins, 8, n) == nullptr); + CHECK(n == 5); +} + +// The same GPIO twice would double-drive one strand — rejected at parse time. +TEST_CASE("parsePinList rejects duplicate pins") { + uint16_t pins[8] = {}; + uint8_t n = 0; + CHECK(mm::parsePinList("18,17,18", pins, 8, n) != nullptr); +} + +// --- assignCounts ----------------------------------------------------------- + +// Explicit "100,100,50" maps one count to each pin by position. +TEST_CASE("assignCounts takes explicit per-pin counts") { + mm::nrOfLightsType counts[8] = {}; + CHECK(mm::assignCounts("100,100,50", 3, 250, counts) == nullptr); + CHECK(counts[0] == 100); + CHECK(counts[1] == 100); + CHECK(counts[2] == 50); +} + +// A short list assigns what it names; unlisted pins share the remaining lights evenly. +TEST_CASE("assignCounts splits the remainder evenly over unlisted pins") { + // 3 pins, only the first has an explicit count: the remaining 150 lights + // split evenly over the remaining 2 pins. + mm::nrOfLightsType counts[8] = {}; + CHECK(mm::assignCounts("100", 3, 250, counts) == nullptr); + CHECK(counts[0] == 100); + CHECK(counts[1] == 75); + CHECK(counts[2] == 75); +} + +TEST_CASE("assignCounts with an empty list splits evenly, last pin takes the rounding remainder") { + mm::nrOfLightsType counts[8] = {}; + CHECK(mm::assignCounts("", 3, 100, counts) == nullptr); + CHECK(counts[0] == 33); + CHECK(counts[1] == 33); + CHECK(counts[2] == 34); +} + +TEST_CASE("assignCounts clamps so the sum never exceeds the buffer") { + mm::nrOfLightsType counts[8] = {}; + // Explicit counts overrun the 250-light buffer: second pin clamps to what's left. + CHECK(mm::assignCounts("200,200", 2, 250, counts) == nullptr); + CHECK(counts[0] == 200); + CHECK(counts[1] == 50); + // A single count larger than the whole buffer clamps to the buffer. + CHECK(mm::assignCounts("300", 1, 250, counts) == nullptr); + CHECK(counts[0] == 250); +} + +TEST_CASE("assignCounts handles a zero-light buffer (0×0×0 grid) as all-zero") { + mm::nrOfLightsType counts[8] = {0xFF, 0xFF, 0xFF}; + CHECK(mm::assignCounts("", 3, 0, counts) == nullptr); + CHECK(counts[0] == 0); + CHECK(counts[1] == 0); + CHECK(counts[2] == 0); +} + +TEST_CASE("assignCounts rejects a bad token") { + mm::nrOfLightsType counts[8] = {}; + CHECK(mm::assignCounts("100,x", 2, 250, counts) != nullptr); +} + +TEST_CASE("assignCounts ignores extra counts beyond the pin list") { + // Robust to any input: a stale longer ledsPerPin after pins shrank is not an + // error — the extra entries just don't apply. + mm::nrOfLightsType counts[8] = {}; + CHECK(mm::assignCounts("10,20,30", 2, 100, counts) == nullptr); + CHECK(counts[0] == 10); + CHECK(counts[1] == 20); +} + +// --- driver-level slicing ---------------------------------------------------- + +TEST_CASE("RmtLedDriver slices the buffer across pins (even split)") { + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.pins, "18,17,16"); + wire(d, src, corr, 90); + + REQUIRE(d.pinCount() == 3); + CHECK(d.pinLightCount(0) == 30); + CHECK(d.pinLightCount(1) == 30); + CHECK(d.pinLightCount(2) == 30); + // Slice i starts at sumBefore(i) × outCh × 8 words. + CHECK(d.pinSymbolOffsetWords(0) == 0); + CHECK(d.pinSymbolOffsetWords(1) == static_cast(30) * 3 * 8); + CHECK(d.pinSymbolOffsetWords(2) == static_cast(60) * 3 * 8); +} + +TEST_CASE("RmtLedDriver slices the buffer per ledsPerPin") { + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.pins, "18,17,16"); + std::strcpy(d.ledsPerPin, "50,20,20"); + wire(d, src, corr, 90); + + REQUIRE(d.pinCount() == 3); + CHECK(d.pinLightCount(0) == 50); + CHECK(d.pinLightCount(1) == 20); + CHECK(d.pinLightCount(2) == 20); + CHECK(d.pinSymbolOffsetWords(1) == static_cast(50) * 3 * 8); + CHECK(d.pinSymbolOffsetWords(2) == static_cast(70) * 3 * 8); +} + +TEST_CASE("RmtLedDriver idles with a status error on a bad pin list") { + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.pins, "18,nope"); + wire(d, src, corr, 64); + + CHECK(d.pinCount() == 0); // no pins → loop() emits nothing + CHECK(d.status() != nullptr); // the parse error is surfaced, not silent + // Recovery: fixing the control and rebuilding clears the error. + std::strcpy(d.pins, "18"); + d.onBuildState(); + CHECK(d.pinCount() == 1); + CHECK(d.status() == nullptr); +} + +TEST_CASE("RmtLedDriver with the empty default pins idles cleanly (no pin assumed)") { + // Pins now default UNSET (the "default only when it cannot do harm" rule — the + // strand is user-soldered). A fresh, unconfigured driver must idle, not grab a + // GPIO: zero pins, a status note, and a crash-safe no-op loop(). + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + REQUIRE(d.pins[0] == '\0'); // the empty default, not a bench guess + wire(d, src, corr, 64); // wire() leaves pins as-is (empty) + + CHECK(d.pinCount() == 0); // nothing claimed + CHECK(d.status() != nullptr); // "set pins" surfaced, not silent + d.loop(); // must be a no-op, not a crash + // Setting pins later brings it live (the user-configures-then-runs flow). + std::strcpy(d.pins, "18"); + d.onBuildState(); + CHECK(d.pinCount() == 1); + CHECK(d.status() == nullptr); +} + +TEST_CASE("RmtLedDriver re-slices when the source buffer changes") { + // setSourceBuffer must recompute counts (the Drivers container re-passes the + // buffer on every buildState) — a grid resize updates the even split. + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.pins, "18,17"); + wire(d, src, corr, 100); + CHECK(d.pinLightCount(0) == 50); + + src.allocate(200, 3); + d.setSourceBuffer(&src); + d.onBuildState(); + CHECK(d.pinLightCount(0) == 100); + CHECK(d.pinLightCount(1) == 100); +} + +// --- loop() robustness ------------------------------------------------------- +// +// loop()'s transmit-all/wait-all concurrency body is gated out on the desktop +// (platform::rmtTxChannels == 0 → it returns at the top), exactly as +// LcdLedDriver::loop() is. So the host can pin only the reachable contract: +// loop() must never crash or overrun for any pin configuration, grid size, or +// uninitialised state. The concurrency path itself (parallel transmit, longest- +// strand cost) is proven on hardware by the real-frame loopback self-test — +// the platform boundary keeps it out of CI, which is by design. + +// loop() is a safe no-op across single-pin, multi-pin and zero-grid configs. +TEST_CASE("RmtLedDriver loop is crash-safe for every pin configuration") { + mm::Correction corr; + corr.rebuild(255, mm::LightPreset::GRB); + + SUBCASE("single pin, populated grid") { + mm::RmtLedDriver d; mm::Buffer src; + std::strcpy(d.pins, "18"); + wire(d, src, corr, 64); + d.loop(); // host: inert; must not crash/overrun + } + SUBCASE("multi-pin even split") { + mm::RmtLedDriver d; mm::Buffer src; + std::strcpy(d.pins, "18,17,16"); + wire(d, src, corr, 90); + REQUIRE(d.pinCount() == 3); + d.loop(); + } + SUBCASE("zero-light grid — counts and offsets stay zero") { + // A 0-light buffer allocates nothing (allocate() returns false by + // design), so wire it by hand rather than through the success-asserting + // helper — the point is that loop() tolerates the empty buffer. + mm::RmtLedDriver d; mm::Buffer src; + std::strcpy(d.pins, "18,17"); + CHECK_FALSE(src.allocate(0, 3)); + d.onBuildControls(); + d.setSourceBuffer(&src); + d.setCorrection(&corr); + d.onBuildState(); + d.loop(); // 0×0×0 must be a clean no-op + } + SUBCASE("loop before any buffer is wired") { + mm::RmtLedDriver d; + d.onBuildControls(); + d.loop(); // uninitialised: the guards must hold + } + CHECK(true); // reached here ⇒ no crash in any subcase +} diff --git a/test/unit/light/unit_RmtLedEncoder.cpp b/test/unit/light/unit_RmtLedEncoder.cpp new file mode 100644 index 00000000..e5d3f0a1 --- /dev/null +++ b/test/unit/light/unit_RmtLedEncoder.cpp @@ -0,0 +1,103 @@ +// @module RmtLedDriver +// @also Correction + +#include "doctest.h" +#include "light/drivers/RmtSymbol.h" +#include "light/drivers/Correction.h" + +#include + +// The encoder is the CI-tier proof of correctness for the LED driver: given +// wire-ordered bytes, it must emit exactly the right WS2812 RMT symbols — +// MSB-first, one symbol per data bit, HIGH for t?hTicks then LOW for the rest of +// the cell. These tests are written test-first (red against the Phase-B stub) +// and pin the contract once the Phase-C implementation lands. + +namespace { + +// Default WS2812B timing at a 40 MHz / 25 ns-per-tick RMT resolution: +// t0h 350 ns -> 14 ticks, t1h 700 ns -> 28 ticks, period 1250 ns -> 50 ticks. +constexpr uint16_t T0H = 14; +constexpr uint16_t T1H = 28; +constexpr uint16_t PERIOD = 50; + +// Decode a symbol word back to its two (level, duration) halves for assertions. +struct Half { uint8_t level; uint16_t duration; }; +Half low16(uint32_t s) { return { static_cast((s >> 15) & 1), static_cast(s & 0x7FFF) }; } +Half high16(uint32_t s) { return { static_cast((s >> 31) & 1), static_cast((s >> 16) & 0x7FFF) }; } + +// Assert one symbol is a correct WS2812 bit: HIGH for `highTicks`, then LOW for +// (PERIOD - highTicks). +void checkBit(uint32_t sym, uint16_t highTicks) { + Half h0 = low16(sym); + Half h1 = high16(sym); + CHECK(h0.level == 1); + CHECK(h0.duration == highTicks); + CHECK(h1.level == 0); + CHECK(h1.duration == static_cast(PERIOD - highTicks)); +} + +} // namespace + +TEST_CASE("encoder: one byte, MSB-first, 0 and 1 bits get the right pulse widths") { + // 0xA5 = 1010 0101, MSB first. + const uint8_t wire[1] = {0xA5}; + uint32_t out[8] = {}; + mm::encodeWs2812Symbols(wire, 1, T0H, T1H, PERIOD, out); + + const uint8_t bits[8] = {1, 0, 1, 0, 0, 1, 0, 1}; // MSB..LSB of 0xA5 + for (int i = 0; i < 8; i++) { + checkBit(out[i], bits[i] ? T1H : T0H); + } +} + +TEST_CASE("encoder: one light's channels emit channels*8 symbols in byte order") { + // encodeWs2812Symbols encodes ONE light of `channels` bytes — the driver calls + // it once per light, so multi-light *ordering* is the driver's concern, not the + // encoder's. Here channels=3 → 3*8 = 24 symbols, byte 0 then byte 1 then byte 2. + const uint8_t wire[3] = {0xFF, 0x00, 0x80}; // byte0 all-ones, byte1 zero, byte2 MSB set + uint32_t out[24] = {}; + mm::encodeWs2812Symbols(wire, 3, T0H, T1H, PERIOD, out); + + checkBit(out[0], T1H); // byte0 MSB = 1 + checkBit(out[7], T1H); // byte0 LSB = 1 (0xFF) + checkBit(out[8], T0H); // byte1 MSB = 0 (0x00) + checkBit(out[15], T0H); // byte1 LSB = 0 + checkBit(out[16], T1H); // byte2 MSB = 1 (0x80) + checkBit(out[17], T0H); // byte2 bit 1 = 0 +} + +TEST_CASE("encoder: GRB ordering comes from Correction, encoder is order-agnostic") { + // Correction with GRB preset turns logical RGB into wire GRB; the encoder then + // just emits the bytes it's handed. Logical red (255,0,0) → wire GRB (0,255,0): + // green byte first. So the FIRST 8 symbols (wire byte 0 = G = 0x00) are all 0s, + // and the SECOND 8 (wire byte 1 = R = 0xFF) are all 1s. + mm::Correction c; + c.rebuild(255, mm::LightPreset::GRB); // full brightness, GRB + const uint8_t logicalRed[3] = {255, 0, 0}; + uint8_t wire[4] = {}; + c.apply(logicalRed, wire); // -> GRB: {0, 255, 0} + + uint32_t out[24] = {}; + mm::encodeWs2812Symbols(wire, c.outChannels, T0H, T1H, PERIOD, out); + + for (int i = 0; i < 8; i++) checkBit(out[i], T0H); // G byte = 0x00 + for (int i = 8; i < 16; i++) checkBit(out[i], T1H); // R byte = 0xFF + for (int i = 16; i < 24; i++) checkBit(out[i], T0H); // B byte = 0x00 +} + +TEST_CASE("encoder: RGBW preset yields 32 symbols per light") { + mm::Correction c; + c.rebuild(255, mm::LightPreset::GRBW); // 4 output channels + CHECK(c.outChannels == 4); + const uint8_t logical[3] = {10, 20, 30}; + uint8_t wire[4] = {}; + c.apply(logical, wire); + + uint32_t out[32] = {}; + mm::encodeWs2812Symbols(wire, c.outChannels, T0H, T1H, PERIOD, out); + // 4 channels * 8 bits = 32 symbols; spot-check the last symbol is a valid bit. + Half h0 = low16(out[31]); + CHECK(h0.level == 1); + CHECK((h0.duration == T0H || h0.duration == T1H)); +}