diff --git a/.claude/workflows/write-behaviour-tests.js b/.claude/workflows/write-behaviour-tests.js new file mode 100644 index 00000000..efbac91e --- /dev/null +++ b/.claude/workflows/write-behaviour-tests.js @@ -0,0 +1,98 @@ +export const meta = { + name: 'write-behaviour-tests', + description: 'Write behaviour-specific unit tests for the 24 untested effects/modifiers', + phases: [ + { title: 'Study + write', detail: 'one agent per module: read the .h, write a faithful behaviour test' }, + ], +} + +// The 24 modules with no unit test (the broken effects.md/modifiers.md [Tests] links). +// Each entry: class name, header path, test-file destination, kind (effect|modifier). +const MODULES = [ + ['BlurzEffect', 'src/light/effects/BlurzEffect.h', 'effect'], + ['BouncingBallsEffect', 'src/light/effects/BouncingBallsEffect.h', 'effect'], + ['FixedRectangleEffect', 'src/light/effects/FixedRectangleEffect.h', 'effect'], + ['FreqMatrixEffect', 'src/light/effects/FreqMatrixEffect.h', 'effect'], + ['FreqSawsEffect', 'src/light/effects/FreqSawsEffect.h', 'effect'], + ['GEQ3DEffect', 'src/light/effects/GEQ3DEffect.h', 'effect'], + ['GEQEffect', 'src/light/effects/GEQEffect.h', 'effect'], + ['LissajousEffect', 'src/light/effects/LissajousEffect.h', 'effect'], + ['Noise2DEffect', 'src/light/effects/Noise2DEffect.h', 'effect'], + ['NoiseMeterEffect', 'src/light/effects/NoiseMeterEffect.h', 'effect'], + ['PaintBrushEffect', 'src/light/effects/PaintBrushEffect.h', 'effect'], + ['PraxisEffect', 'src/light/effects/PraxisEffect.h', 'effect'], + ['RandomEffect', 'src/light/effects/RandomEffect.h', 'effect'], + ['RubiksCubeEffect', 'src/light/effects/RubiksCubeEffect.h', 'effect'], + ['SolidEffect', 'src/light/effects/SolidEffect.h', 'effect'], + ['SphereMoveEffect', 'src/light/effects/SphereMoveEffect.h', 'effect'], + ['StarFieldEffect', 'src/light/effects/StarFieldEffect.h', 'effect'], + ['StarSkyEffect', 'src/light/effects/StarSkyEffect.h', 'effect'], + ['TetrixEffect', 'src/light/effects/TetrixEffect.h', 'effect'], + ['BlockModifier', 'src/light/modifiers/BlockModifier.h', 'modifier'], + ['CircleModifier', 'src/light/modifiers/CircleModifier.h', 'modifier'], + ['MirrorModifier', 'src/light/modifiers/MirrorModifier.h', 'modifier'], + ['RippleXZModifier', 'src/light/modifiers/RippleXZModifier.h', 'modifier'], + ['TransposeModifier', 'src/light/modifiers/TransposeModifier.h', 'modifier'], +] + +const RESULT = { + type: 'object', + properties: { + module: { type: 'string' }, + test_file: { type: 'string' }, + behaviours_pinned: { type: 'array', items: { type: 'string' }, + description: 'one line per TEST_CASE: what real behaviour it asserts' }, + wrote: { type: 'boolean', description: 'true if the test file was written' }, + notes: { type: 'string', description: 'anything the caller should know (e.g. audio-driven, needs a fed AudioFrame; or a behaviour that could not be pinned)' }, + }, + required: ['module', 'test_file', 'wrote'], +} + +phase('Study + write') + +const results = await parallel(MODULES.map(([cls, header, kind]) => () => + agent( +`Write a behaviour-specific doctest unit test for the projectMM module **${cls}** (${kind}). + +Repo: the current workspace root (the projectMM checkout you're running in) — all paths below are relative to it. + +## Study first (do NOT guess behaviour) +1. Read the module header: ${header} — understand what it ACTUALLY does: its controls, its render/modify logic, what it writes to the buffer or how it transforms coordinates. Behaviour is the spec. +2. Read the module's spec entry if useful: docs/moonmodules/light/${kind === 'effect' ? 'effects/effects.md' : 'modifiers/modifiers.md'} (find the ${cls.replace(/Effect$|Modifier$/, '')} section). +3. Read TWO existing tests as your pattern templates — match their idiom EXACTLY (includes, harness, naming, comment style): + - For an EFFECT: test/unit/light/unit_RainbowEffect.cpp (Layouts→GridLayout→Layer→addChild(effect)→onBuildState()→loop()→assert on layer.buffer()). + - For a MODIFIER: test/unit/light/unit_RegionModifier.cpp (call modifyLogical / modifyLogicalSize directly; assert coordinate folding / size). + Pick the one matching this module's kind (${kind}). + +## Write the test +- Destination: test/unit/light/unit_${cls}.cpp +- First line MUST be: \`// @module ${cls}\` (this is what the doc generator + MoonDeck read — the whole point is that this module becomes a documented, tested module). Add \`// @also X, Y\` only if the test genuinely also exercises another module. +- Each TEST_CASE gets a single \`//\` comment line ABOVE it describing the behaviour it pins (the generator turns that into the doc description). Write real, present-tense descriptions. +- Assert REAL BEHAVIOUR, not just "renders non-zero". Examples of the bar: + - SolidEffect → the whole buffer is ONE uniform colour (every light equals the configured colour). + - FixedRectangleEffect → only lights inside the configured rect are lit; outside is black; defaults (0,0,0)+(15,15,15) light the origin corner. + - MirrorModifier → a coord and its mirror map to the same logical position; modifyLogicalSize halves the mirrored axis (study the .h for which axis/percentage). + - TransposeModifier → swaps axes (x↔y etc. per the .h); modifyLogicalSize swaps the corresponding size fields. + - FreqMatrix/GEQ/FreqSaws/NoiseMeter are AUDIO effects → they read an AudioFrame. Study how an existing audio path is tested or how the effect gets its data (look for AudioModule / an audio-frame accessor). If the effect needs a fed audio frame to produce output, set it up; if you truly cannot feed audio in a unit test, pin what you CAN (runs at multiple grid sizes incl 0×0 without crashing — the "Effects must run at every grid size" hard rule — and any non-audio behaviour) and say so in notes. +- Respect the hard rule: include a case that runs the effect at a DEGENERATE grid (0×0×0 or 1×1) and asserts no crash, where sensible. +- Keep it to 2–4 focused TEST_CASEs. Match the exact include style and mm:: namespace usage of the template. +- Use doctest macros (TEST_CASE / CHECK / REQUIRE) exactly as the templates do. Do NOT add the file to any CMakeLists — the test build globs test/unit/**. + +## Do NOT +- Do NOT run the build or ctest (the caller compiles everything once at the end — per-agent builds would thrash). +- Do NOT edit any file other than creating test/unit/light/unit_${cls}.cpp. +- Do NOT invent controls or behaviour the .h doesn't have. + +Return the structured result: the behaviours you pinned (one line each) and any notes (especially if audio-driven or a behaviour you couldn't pin).`, + { label: `test:${cls}`, phase: 'Study + write', schema: RESULT, agentType: 'general-purpose' } + ) +)) + +const wrote = results.filter(r => r && r.wrote) +const failed = results.filter(r => !r || !r.wrote) +return { + written: wrote.length, + failed: failed.map(r => r ? r.module : 'unknown'), + audio_or_caveats: results.filter(r => r && r.notes).map(r => ({ module: r.module, notes: r.notes })), + summary: results.filter(Boolean).map(r => ({ module: r.module, file: r.test_file, behaviours: r.behaviours_pinned })), +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52381d15..18c2fb5e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,12 +26,15 @@ on: - 'CMakeLists.txt' - 'library.json' - '.github/workflows/release.yml' - # The web installer + landing page are served from Pages by the deploy-pages job + # The web installer + docs site are served from Pages by the deploy-pages job # below; a change to them must trigger a deploy or it never reaches the live site # (the eth-only-provisioning fix shipped a commit that didn't auto-deploy because - # docs/install was missing here). src/ui/install-picker*.js is already covered by src/**. - - 'docs/install/**' - - 'docs/landing/**' + # web-installer was missing here). src/ui/install-picker*.js is already covered by src/**. + # docs/** covers every page rendered into the Pages root by MkDocs; mkdocs.yml is + # the site config (nav/theme) — a nav change with no doc change must still redeploy. + - 'docs/**' + - 'web-installer/**' + - 'mkdocs.yml' workflow_dispatch: inputs: tag: @@ -78,7 +81,7 @@ jobs: TAG: ${{ inputs.tag || github.ref_name }} run: uv run scripts/build/verify_version.py --tag "$TAG" - # The shipping firmware list, read from the generated docs/install/firmwares.json + # The shipping firmware list, read from the generated web-installer/firmwares.json # (projected from build_esp32.py's FIRMWARES dict, drift-guarded by # check_firmwares.py). Emitted as a JSON array so build-esp32's matrix can # fromJSON() it — GitHub matrices can't read a file at parse time, so a job @@ -95,7 +98,7 @@ jobs: - id: gen run: | set -euo pipefail - echo "list=$(jq -c '[.firmwares[] | select(.ships) | .name]' docs/install/firmwares.json)" >> "$GITHUB_OUTPUT" + echo "list=$(jq -c '[.firmwares[] | select(.ships) | .name]' web-installer/firmwares.json)" >> "$GITHUB_OUTPUT" build-esp32: needs: [verify-version, firmwares] @@ -338,9 +341,9 @@ jobs: # — no CORS). The Pages-relative manifests are generated in the # deploy-pages job, where the web installer (CORS-bound) consumes them. BASE="https://github.com/${REPO}/releases/download/$TAG" - # The shipping firmware list — the same docs/install/firmwares.json the + # The shipping firmware list — the same web-installer/firmwares.json the # build matrix reads, so manifests and builds can't drift. - for F in $(jq -r '.firmwares[] | select(.ships) | .name' docs/install/firmwares.json); do + for F in $(jq -r '.firmwares[] | select(.ships) | .name' web-installer/firmwares.json); do uv run python scripts/build/generate_manifest.py \ --firmware "$F" \ --version "$V" \ @@ -514,7 +517,7 @@ jobs: # Install page + the shared install-picker module sit at the root. # Each release's binaries + manifests live under releases//. mkdir -p pages/install - cp -r docs/install/. pages/install/ + cp -r web-installer/. pages/install/ cp src/ui/install-picker.js pages/install/ # The board-catalog / chip-detection half of the picker — web-installer # only (not embedded in firmware), imported by index.html. Must ship to @@ -530,16 +533,44 @@ jobs: mkdir -p pages/install/assets/boards # rel is "assets/boards/." (the path served from /install/); # the source file lives in docs/ (i.e. docs/assets/boards/...). - jq -r '.[].image // empty' docs/install/deviceModels.json | while read -r rel; do + jq -r '.[].image // empty' web-installer/deviceModels.json | while read -r rel; do src="docs/$rel" [ -f "$src" ] && cp "$src" "pages/install/$rel" \ || echo "WARNING: deviceModels.json image not found: $src" done - # 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/ + # gen_api.py runs Doxygen -> moxygen to generate the per-module technical pages + # (moonmodules/{core,light}/moxygen/*.md) from each `.h`'s /// comments at + # MkDocs-build time. moxygen runs via `npx moxygen@2.1.10`, so Node must be + # present; pin it explicitly (rather than lean on the runner image's default) so + # a future image change can't silently drop npx or shift its behaviour. Doxygen + # is the one apt binary we add — the justified non-uv dependency (like ESP-IDF's + # Python). With the tools present, a moxygen/doxygen failure now RAISES (gen_api + # GenApiError) and fails this build, rather than shipping a site with no API pages. + - name: Set up Node (for npx moxygen) + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install Doxygen (source-generated API pages) + run: | + set -euo pipefail + sudo apt-get update && sudo apt-get install -y doxygen + + - name: Build docs site into Pages root + run: | + set -euo pipefail + # Render the docs/ tree (Material for MkDocs) as the Pages ROOT + # (moonmodules.github.io/projectMM/) — the project's front door. + # Config: mkdocs.yml; deps declared inline in the build script (uv + # provisions them). Build to a temp dir, then copy INTO pages/ so the + # installer staged above under pages/install/ survives (a plain + # --site-dir pages would wipe it — mkdocs cleans its output dir). + # history/ and backlog/ are excluded in mkdocs.yml (internal docs). + uv run scripts/docs/build_docs.py --site-dir "$RUNNER_TEMP/docs-site" + cp -r "$RUNNER_TEMP/docs-site/." pages/ + ls -la pages/ + - uses: actions/upload-pages-artifact@v3 with: path: pages diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8af54813..ee68d4eb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ name: Test # New Python/JS unit suites land under test/python and test/js and run here. # Paths cover every input to the host-side tests: the Python/JS sources under test -# (scripts, docs/install), the test files themselves, AND the device-side C++ frame +# (scripts, web-installer), the test files themselves, AND the device-side C++ frame # contract (src/core/Improv*.h + the platform handler) — a wire-format change in the # firmware must run the cross-language golden-vector tests so it can't drift from the # Python/JS builders silently. pull_request gates every PR; push runs main only (a @@ -20,7 +20,7 @@ on: pull_request: paths: &test-paths - 'scripts/**' - - 'docs/install/**' + - 'web-installer/**' - 'src/core/ImprovFrame.h' - 'src/core/ImprovOpReassembler.h' - 'src/platform/esp32/platform_esp32_improv.cpp' @@ -45,9 +45,11 @@ jobs: - uses: astral-sh/setup-uv@v3 # pytest + pyserial come from the test file's inline PEP-723 block; passing # them via --with is the explicit, discovery-friendly form (a bare `pytest - # ` doesn't honour a test file's own inline deps). + # ` doesn't honour a test file's own inline deps). `markdown` (a MkDocs + # dep, not in the base env) is needed by test_mkdocs_slug.py, which pins _slug() + # against Python-Markdown's real toc slugify. - name: pytest - run: uv run --with pytest --with pyserial pytest test/python -q + run: uv run --with pytest --with pyserial --with markdown pytest test/python -q js: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index ae20936b..1042d4c2 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,15 @@ Thumbs.db # Python __pycache__/ *.pyc + +# MkDocs — built docs site (rendered fresh in CI, never committed) +/site/ + +# Generated test inventory pages — built into the site by scripts/docs/mkdocs_hooks.py +# (the CLI generate_test_docs.py can still write them locally, but they are not committed). +/docs/tests/*.md + +# Generated per-module technical pages — Doxygen+moxygen from each `.h`, built into +# the site by scripts/docs/mkdocs_hooks.py (via gen_api.py). Regenerated every build. +/docs/moonmodules/core/moxygen/ +/docs/moonmodules/light/moxygen/ diff --git a/CLAUDE.md b/CLAUDE.md index d8328bf7..e8c40fec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,15 +113,15 @@ The narrow safety net: "this snapshot is internally consistent." 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. -8. Device-model catalog, `check_devices.py`, fast (<1s), if `docs/install/deviceModels.json` or `scripts/check/check_devices.py` changed. Validates the installer catalog: required fields, `firmwares` a non-empty list of non-empty strings (`firmwares[0]` is the default), every `image` resolves on disk, each entry's `System.deviceModel` control equals its entry `name`, module `type`s are factory-registered (or boot-wired singletons), `pins` controls live only on `*LedDriver` modules, and `supported` capabilities stay within the known vocabulary. -9. Firmware list, `check_firmwares.py`, fast (<1s), if `scripts/build/build_esp32.py`, `docs/install/firmwares.json`, or `scripts/check/check_firmwares.py` changed. Regenerates the firmware projection from the `FIRMWARES` dict and fails on drift from the committed `docs/install/firmwares.json` (so a `FIRMWARES` edit without regenerating is caught). Trigger includes `build_esp32.py` because that dict is the upstream source. -10. Host-side unit tests (Python + JS), fast (<2s), the suites the C++ ctest can't reach (MoonDeck / build-script logic, web-installer logic). Run `uv run --with pytest --with pyserial pytest test/python -q` if any file under `scripts/` or `test/python/` changed, and `node --test "test/js/**/*.test.mjs"` if any file under `docs/install/` or `test/js/` changed. (Same suites the PR-triggered `.github/workflows/test.yml` runs.) Today these pin the Improv frame wire format — `test/python` + `test/js` assert a shared golden vector so the device C++, Python, and JS frame builders can't drift. New Python/JS unit suites land under `test/python` / `test/js` and run here. +8. Device-model catalog, `check_devices.py`, fast (<1s), if `web-installer/deviceModels.json` or `scripts/check/check_devices.py` changed. Validates the installer catalog: required fields, `firmwares` a non-empty list of non-empty strings (`firmwares[0]` is the default), every `image` resolves on disk, each entry's `System.deviceModel` control equals its entry `name`, module `type`s are factory-registered (or boot-wired singletons), `pins` controls live only on `*LedDriver` modules, and `supported` capabilities stay within the known vocabulary. +9. Firmware list, `check_firmwares.py`, fast (<1s), if `scripts/build/build_esp32.py`, `web-installer/firmwares.json`, or `scripts/check/check_firmwares.py` changed. Regenerates the firmware projection from the `FIRMWARES` dict and fails on drift from the committed `web-installer/firmwares.json` (so a `FIRMWARES` edit without regenerating is caught). Trigger includes `build_esp32.py` because that dict is the upstream source. +10. Host-side unit tests (Python + JS), fast (<2s), the suites the C++ ctest can't reach (MoonDeck / build-script logic, web-installer logic). Run `uv run --with pytest --with pyserial pytest test/python -q` if any file under `scripts/` or `test/python/` changed, and `node --test "test/js/**/*.test.mjs"` if any file under `web-installer/` or `test/js/` changed. (Same suites the PR-triggered `.github/workflows/test.yml` runs.) Today these pin the Improv frame wire format — `test/python` + `test/js` assert a shared golden vector so the device C++, Python, and JS frame builders can't drift. New Python/JS unit suites land under `test/python` / `test/js` and run here. -A commit that touches *only* `.github/`, `docs/` (excluding `docs/install/`), `README.md`, `CLAUDE.md`, or `.claude/` therefore runs only the spec check (plus the board-catalog and firmware checks when their specific files changed); the build/test/ESP32/KPI gates are no-ops because their triggers don't fire. A `scripts/` or `docs/install/` change adds the relevant host-side unit-test gate but still skips the C++ build/ESP32/KPI gates. This is the intended pre-commit cost for CI-only or doc-only changes. +A commit that touches *only* `.github/`, `docs/`, `README.md`, `CLAUDE.md`, or `.claude/` therefore runs only the spec check (plus the board-catalog and firmware checks when their specific files changed); the build/test/ESP32/KPI gates are no-ops because their triggers don't fire. A `scripts/` change adds the Python host-side unit-test gate, and a `web-installer/` change adds the JS one, but both still skip the C++ build/ESP32/KPI gates. 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`, `web-installer/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. @@ -207,19 +207,12 @@ docs/ plans/ ← approved feature plans (Plan-YYYYMMDD - .md; PO reference, agents don't auto-read) *-inventory.md ← prior-project surveys (v1, v2, moonlight) <repo>.md ← friend-repo monthly activity digests (FastLED, WLED, …) - moonmodules/ ← one page per MoonModule (specs before code) + moonmodules/ ← catalog pages (effects/modifiers/layouts/drivers) + core/light detail pages (see coding-standards § Documentation model) ``` Documentation describes the system as it is. Git commits are the history. Module specs are written before implementation. Doc pages are kept current with the code. -**Module specs are end-user / API-integrator documentation, not tech documentation.** Each `docs/moonmodules/<Name>.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. - -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. +**Documentation model** — the full standard lives in [docs/coding-standards.md § Documentation model](docs/coding-standards.md#documentation-model); the working-memory summary: the four **catalog pages** (`effects/modifiers/layouts/drivers.md`, authored as prose `### ` blocks, rendered as tables by the build hook) are the end-user documentation. A per-module detail `.md` exists **only** for cross-file wiring / design rationale no single source file owns (and doesn't exist otherwise). Test inventories + catalog tables are **generated at build time, not committed**. Where a fact is the same in the `.h` and a doc (a control range, a source URL), `check_specs.py` **validates** they agree rather than duplicating; where a doc would hand-copy a struct/enum/wire format, embed the real source via a `--8<--` snippet. Never re-type a fact the `.h` already states. The `history/` folder is the distilled experience of years of building LED/light systems, from WLED, WLED-MM, StarLight, MoonLight, through projectMM. It contains proven patterns, memory tricks, control mechanisms, and hard-won lessons, studied under the [*Industry standards, our own code*](#principles) principle. Per-project credits live in the `history/` digests and the per-module "Prior art" sections. diff --git a/docs/architecture.md b/docs/architecture.md index 2257f900..89f5f64f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,7 +75,7 @@ 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/moxygen/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/moxygen/Scheduler.md). This means: @@ -109,11 +109,11 @@ ModuleFactory is core infrastructure ([`src/core/ModuleFactory.h`](../src/core/M **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. +Each MoonModule has two documentation surfaces under [`docs/moonmodules/`](moonmodules/): an end-user **summary page** — one 4-column table row in its group's page (effects/modifiers/layouts/drivers, or core/light UI/supporting) — and a **generated technical page** built from the header's `///` comments. See [coding-standards § Documentation model](coding-standards.md#documentation-model) for the full model. ## 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/moxygen/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)). @@ -121,11 +121,11 @@ Controls are dynamic: when a value changes, the control set can be rebuilt. A se Prefer `uint8_t` (0–255) for slider controls. Minimises per-control memory, aligns with DMX channel values, keeps the UI range manageable. -Controls are the bridge between the [web UI](moonmodules/core/ui.md) and the running module tree: the UI renders a control from what the MoonModule declares, and a value the user changes there writes straight back into the module's member variable. The exact control types (slider, toggle, colour picker, text input, dropdown) are defined in the [UI spec](moonmodules/core/ui.md). The principle: modules declare what they need, the UI renders it. +Controls are the bridge between the [web UI](moonmodules/core/ui/ui.md) and the running module tree: the UI renders a control from what the MoonModule declares, and a value the user changes there writes straight back into the module's member variable. The exact control types (slider, toggle, colour picker, text input, dropdown) are defined in the [UI spec](moonmodules/core/ui/ui.md). The principle: modules declare what they need, the UI renders it. ## Persistence -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): +Control values and each module's `enabled` flag are persisted to flash so settings survive a reboot. The mechanism lives in [FilesystemModule](moonmodules/core/moxygen/FilesystemModule.md): - **Storage**: one flat JSON file per top-level module under `/.config/<TypeName>.json`. Children are encoded positionally with `<index>.` key prefixes — a deliberately flat file shape loaded by the cheap first-match key helpers in `core/JsonUtil.h`. A control whose *value* is structured (a List control's array of objects) round-trips that array with the recursive reader in the same header, via the control's own restore hook; the file's top level stays flat, the structure lives inside one control's value. - **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. @@ -230,7 +230,7 @@ Three distinct things, kept distinct in the vocabulary: **Firmware** is the compiled binary: chip target plus which radios/peripherals/sdkconfig fragments are included. Today's variants: `esp32` (classic, WiFi **and** RMII Ethernet in one binary — Ethernet comes up only when a PHY is present, pins/PHY per deviceModel), `esp32-eth` (classic, Ethernet only, WiFi excluded), `esp32-16mb` (classic with 16 MB flash, WiFi + Ethernet), `esp32s3-n16r8` / `esp32s3-n8r8` (S3 with WiFi + W5500 SPI Ethernet), `esp32p4-eth` (Waveshare ESP32-P4-NANO, Ethernet only), `esp32p4-eth-wifi` (the same P4 hardware with WiFi via its on-board ESP32-C6 over esp_hosted). Each chip's firmware carries the Ethernet *driver(s)* it can host (RMII EMAC for classic/P4, W5500 SPI for S3); which PHY/pins a deviceModel uses is runtime config. Selected by `build_esp32.py --firmware <key>`, reported by `SystemModule.firmware`, used as the contract target key in scenarios. -**deviceModel** is the physical hardware: chip + PCB + on-board peripherals (PHY, USB-serial, PSRAM, antenna), identified by its product name. Examples: `Olimex ESP32-Gateway Rev G`, `LOLIN D32`, `Generic ESP32 Dev`. A unit cannot identify its own deviceModel (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. It is stored on the unit as SystemModule's `deviceModel` Text control (display-only in the UI; HTTP `/api/control` writes still apply). MoonDeck mirrors the picked / deduced value to the unit via `POST /api/control` after each discover and after every dropdown change. The catalog of valid deviceModels lives at [docs/install/deviceModels.json](install/deviceModels.json), shared between MoonDeck and the web installer: MoonDeck reads it for its dropdown and HTTP push (plain REST on the LAN); the web installer reads it for its picker and pushes the whole entry — deviceModel plus every module/control — over serial during provisioning as REST ops (**"Improv = REST over serial"**, the `APPLY_OP` vendor RPC; see [ImprovProvisioningModule.md](moonmodules/core/ImprovProvisioningModule.md)). Pushing over serial sidesteps the mixed-content block that stops an HTTPS installer page from POSTing to an `http://` device; an already-running device is re-configured via MoonDeck on the LAN. +**deviceModel** is the physical hardware: chip + PCB + on-board peripherals (PHY, USB-serial, PSRAM, antenna), identified by its product name. Examples: `Olimex ESP32-Gateway Rev G`, `LOLIN D32`, `Generic ESP32 Dev`. A unit cannot identify its own deviceModel (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. It is stored on the unit as SystemModule's `deviceModel` Text control (display-only in the UI; HTTP `/api/control` writes still apply). MoonDeck mirrors the picked / deduced value to the unit via `POST /api/control` after each discover and after every dropdown change. The catalog of valid deviceModels lives at [web-installer/deviceModels.json](../web-installer/deviceModels.json), shared between MoonDeck and the web installer: MoonDeck reads it for its dropdown and HTTP push (plain REST on the LAN); the web installer reads it for its picker and pushes the whole entry — deviceModel plus every module/control — over serial during provisioning as REST ops (**"Improv = REST over serial"**, the `APPLY_OP` vendor RPC; see [ImprovProvisioningModule.md](moonmodules/core/moxygen/ImprovProvisioningModule.md)). Pushing over serial sidesteps the mixed-content block that stops an HTTPS installer page from POSTing to an `http://` device; an already-running device is re-configured via MoonDeck on the LAN. A deviceModel can run multiple firmwares (the Olimex Gateway runs both `esp32-eth` and the default `esp32`); a firmware can run on multiple deviceModels (`esp32` runs on any classic ESP32 dev kit). The `esp32s3-n16r8` firmware is S3-only and does not run on the Olimex Gateway or other classic-ESP32 hardware. The codebase reserves "deviceModel" exclusively for the physical product and "firmware" exclusively for the compiled binary. @@ -247,7 +247,7 @@ So the Ethernet pins live at **both** levels, and that's not a contradiction: th **The governing rule — "default only where the hardware actually fixes it"** — is the [`assign defaults only when they cannot do harm`](history/decisions.md) rule applied to pin provenance. A control whose value the product fixes (Ethernet PHY pins, status LED) *should* default (and *omitting* it harms — a deviceModel with un-defaulted Ethernet pins can never connect); a control the *user* wires (mic pins, LED-driver pins) must not default — any guess can drive a pin the user wired elsewhere — so it stays unset until set. This is enforced purely by **what each catalog entry lists**: an entry defaults a control by *including* it, and leaves a user-wired control unset by *omitting* it. No level-tagging needed — the data carries the rule. -The rule covers **settings, not just pins**. `txPowerSetting` is the worked example: whether the assembled rig can *sustain* the current spike of full-power WiFi TX depends on the product's power regulation and how the user powers it (USB port, PSU, cable) — a brownout property of the physical product, not the chip. In-tree, the catalog sets `Network.txPowerSetting: 8` for the ESP32-S3 N16R8 Dev, which browns out at full power on typical USB; the entry *suggests* the safe floor, and a unit can lower it further for a weak supply. The general lesson: any setting whose safe value depends on the physical assembly or its power, not the chip, is set by the deviceModel's catalog entry. (The catalog is [`install/deviceModels.json`](install/deviceModels.json); see the [installer README](install/README.md) for its schema.) +The rule covers **settings, not just pins**. `txPowerSetting` is the worked example: whether the assembled rig can *sustain* the current spike of full-power WiFi TX depends on the product's power regulation and how the user powers it (USB port, PSU, cable) — a brownout property of the physical product, not the chip. In-tree, the catalog sets `Network.txPowerSetting: 8` for the ESP32-S3 N16R8 Dev, which browns out at full power on typical USB; the entry *suggests* the safe floor, and a unit can lower it further for a weak supply. The general lesson: any setting whose safe value depends on the physical assembly or its power, not the chip, is set by the deviceModel's catalog entry. (The catalog is [`web-installer/deviceModels.json`](../web-installer/deviceModels.json); see the [installer README](../web-installer/README.md) for its schema.) ## Peripherals @@ -257,7 +257,7 @@ A **peripheral** is a MoonModule (role `ModuleRole::Peripheral`) that bridges to 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 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. +Peripherals are **user-add/deletable children of SystemModule**: the firmware is identical whether or not the hardware is wired, so the user adds the module when they solder a gyro on and removes it later, reusing the generic child add/replace/delete + persistence machinery (SystemModule declares `acceptsChildRoles("peripheral")`). Direction is per-module, not a role: a peripheral may read (gyro), write (relay), or both, so one `Peripheral` role spans the category. Each is a header-only or `.h`+`.cpp` core module under `src/core/`, reaches hardware only through a domain-neutral platform primitive (`platform::i2c*`, `platform::audioMic*`, …), and gets a spec in `docs/moonmodules/core/` (enforced by `check_specs.py`). Most poll in `loop20ms`/`loop1s`; the exception is a peripheral whose data an effect consumes *every frame*: [AudioModule](moonmodules/core/moxygen/AudioModule.md) reads + analyses its I²S microphone in `loop()` because the audio effects react per render tick, and its per-tick cost (one FFT) is part of the render budget. Automatic bus-probe detection is out of scope; the manual path is the foundation. **An effect reads a peripheral's data** via the shared-struct pull pattern from [§ Data exchange](#data-exchange-between-modules), no new mechanism: the peripheral owns a small POD struct overwritten in place each poll/tick, and the consuming effect holds a `const` pointer to it. The first concrete case is audio: AudioModule produces an `AudioFrame` (level + 16-band spectrum + peak) that [AudioVolumeEffect](moonmodules/light/effects/effects.md) and [AudioSpectrumEffect](moonmodules/light/effects/effects.md) consume. It reaches the frame through a static `AudioModule::latestFrame()` rather than a boot-time setter, a small variation on the pattern, because an audio effect can be added through the UI *after* boot and must still find the one live mic (a setter only wired the boot instance). The active mic registers itself in `setup()` and clears the pointer in `teardown()`, so add/remove in any order returns either the live frame or a static silent one, never null. A peripheral that only *displays* its readings (the gyro today) skips the consumer side entirely. @@ -274,7 +274,7 @@ What the synced clock is *for* is a domain question; the light domain's use of i A device has **one** network name, `deviceName`, and every name the device presents on the network is that exact string: the mDNS hostname (`<deviceName>.local`), the SoftAP SSID (the captive-portal network shown when unprovisioned), and the DHCP hostname (what the router's client list shows). They are not three settings that happen to match — there is a single source and the others *read* it, so a device shows one identity everywhere and the three can never drift apart. -- **Sole owner: `SystemModule`.** `deviceName` is a control on `SystemModule` (default `MM-XXXX` from the MAC). It is the only place the name is stored or edited. Every consumer reads `SystemModule::deviceName()`; no other module holds a name of its own. `NetworkModule` reads it for the mDNS / AP / DHCP names; `main.cpp` reads it for the `MM_DEVICE=<deviceName>.local` boot-serial token the [web installer](install/README.md) uses to offer a clickable `.local` link. So to know what name a device advertises, you read one accessor — you never inspect NetworkModule or the platform to discover it. +- **Sole owner: `SystemModule`.** `deviceName` is a control on `SystemModule` (default `MM-XXXX` from the MAC). It is the only place the name is stored or edited. Every consumer reads `SystemModule::deviceName()`; no other module holds a name of its own. `NetworkModule` reads it for the mDNS / AP / DHCP names; `main.cpp` reads it for the `MM_DEVICE=<deviceName>.local` boot-serial token the [web installer](../web-installer/README.md) uses to offer a clickable `.local` link. So to know what name a device advertises, you read one accessor — you never inspect NetworkModule or the platform to discover it. - **Always a valid hostname.** Because all three uses are DNS/SSID names, `deviceName` must satisfy the RFC-1123 label rules (`[A-Za-z0-9-]`, no spaces, no leading/trailing hyphen). `SystemModule` enforces this at the source: it runs `mm::sanitizeHostname()` (in `core/Control.h`) on the value in `setup()` and every `loop1s()`, coercing whatever the user typed or persistence restored (`"My Living Room!"` → `"My-Living-Room"`) and falling back to the MAC-derived `MM-XXXX` if the result is empty. Sanitising *at the owner* means every consumer is correct for free — no per-consumer validation, no chance a raw name reaches mDNS. (`unit_sanitizeHostname` pins the rule.) - **Follows a live rename.** Renaming the device re-advertises immediately, no reboot — the [live-reconfiguration](#live-reconfiguration-every-change-applies-without-a-reboot) rule applied to identity. `NetworkModule::syncMdns()` (called each `loop1s()`) compares the current name to the last-registered one and re-registers mDNS when it changed, so `<new-name>.local` resolves within a tick. @@ -355,7 +355,7 @@ A **Layer** (a MoonModule, child of Layers) owns: A layer can have **multiple effects**. Each effect writes to the buffer sequentially in its listed order, overwriting or adding to the previous — so the effects stack (a base-colour effect followed by a sparkle effect). -A layer applies **all its enabled modifiers as a chain** during the mapping build (`Layer::rebuildLUT`): each modifier is a coordinate fold, and they compose in child order (M₁∘M₂∘…). Modifiers are **reorderable** in the UI, and order is meaningful (a multiply-then-checkerboard mask differs from checkerboard-then-multiply, just as mirror-then-rotate differs from rotate-then-mirror). The fold contract (the three hooks, the physical→logical build, the live pass) is documented in [ModifierBase](moonmodules/light/ModifierBase.md). +A layer applies **all its enabled modifiers as a chain** during the mapping build (`Layer::rebuildLUT`): each modifier is a coordinate fold, and they compose in child order (M₁∘M₂∘…). Modifiers are **reorderable** in the UI, and order is meaningful (a multiply-then-checkerboard mask differs from checkerboard-then-multiply, just as mirror-then-rotate differs from rotate-then-mirror). The fold contract (the three hooks, the physical→logical build, the live pass) is documented in [ModifierBase](moonmodules/light/moxygen/ModifierBase.md). Each layer references the shared Layouts. The layer builds its mapping by walking the Layouts container's **physical** coordinates and folding each through the static modifier chain to its logical cell — N physical lights folding onto one logical cell is the fan-out (a Multiply kaleidoscope), so the build never produces a fan-out overflow. Different layers in Layers can have different modifiers, producing different mappings from the same Layouts. @@ -431,7 +431,7 @@ A recompile is the normal cold-path rebuild: editing the `source` control routes 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 layer applies **all** its enabled modifiers as a chain, in child order — each a coordinate fold composed into one mapping (see [§ Layers and Layer](#layers-and-layer)). -A modifier is a coordinate transform, applied in one of two ways (the fold contract is in [ModifierBase](moonmodules/light/ModifierBase.md)): +A modifier is a coordinate transform, applied in one of two ways (the fold contract is in [ModifierBase](moonmodules/light/moxygen/ModifierBase.md)): - **Static** (`modifyLogicalSize` + `modifyLogical`): folded into the mapping during the cold-path build, so it costs nothing per frame (Region crop, Multiply tile/mirror, a mask). - **Live** (`modifyLive`): a per-frame coordinate remap for animation (rotation), run only when an enabled modifier needs it — a static-only chain pays nothing. @@ -547,7 +547,7 @@ The UI is **MoonModule-driven**. It contains no hard-coded knowledge of specific Adding a new MoonModule with controls needs **zero changes** to the UI files. This extends to the tree-mutation affordances: which modules accept children (and of what role) comes from each type's `acceptsChildRoles()`, and whether a module can be deleted/replaced comes from its `userEditable()`: both declared on the C++ side and reported in `/api/types` + `/api/state`. The UI hardcodes no list of "which types are containers" or "which roles are editable"; a new container type or a fixed child is a one-line C++ override. -The light domain plugs into the UI at three points: a fixed top-level tree (Layouts / Layers / Drivers pinned in `main.cpp`, root reorder disabled while child reorder works via drag-and-drop), a binary WebSocket preview channel ([PreviewDriver](moonmodules/light/drivers/PreviewDriver.md): a `0x03` coordinate table sent once per LUT rebuild plus per-frame `0x02` RGB point lists, so sparse layouts preview at their real positions), and per-role emoji for the chip filter (the `ROLE_EMOJI` map in `app.js` is the single source of truth: `effect`, `driver`, …, `peripheral`). Full UI spec: [docs/moonmodules/core/ui.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/ui.md](moonmodules/core/ui/ui.md). ## Tag emoji legend diff --git a/docs/assets/extra.css b/docs/assets/extra.css new file mode 100644 index 00000000..b632901f --- /dev/null +++ b/docs/assets/extra.css @@ -0,0 +1,88 @@ +/* Catalog tables (effects / modifiers / layouts / drivers), rendered from prose + ### blocks by scripts/docs/mkdocs_hooks.py. Give the four columns sensible + proportions and let the preview image fill its column instead of the tiny + hand-authored width="300". + + Scoped to `.mm-catalog` (a class we add to the rendered table) so it doesn't + reshape the generated test-inventory tables or any other table on the site. */ + +/* Material tables default to width:auto with content-based column sizing, which + IGNORES percentage column widths (the Preview column collapsed to ~100px and + the GIF rendered as a 68px thumbnail). `table-layout: fixed` + full width makes + the browser honour the column percentages below. */ +.md-typeset .mm-catalog-wrap table { + table-layout: fixed; + width: 100%; +} + +/* The preview GIF/PNG fills its (now properly-sized) column; auto height keeps + the aspect ratio. No max-width — let it use the full column so previews are big. */ +.md-typeset .mm-catalog-wrap table td img.mm-preview { + width: 100%; + height: auto; + display: block; +} + +/* Column proportions: name+description and parameters get the room; preview and + links are narrower. nth-child targets the 4 columns of the catalog table. */ +.md-typeset .mm-catalog-wrap table th:nth-child(1), +.md-typeset .mm-catalog-wrap table td:nth-child(1) { width: 26%; } /* Name + description */ +.md-typeset .mm-catalog-wrap table th:nth-child(2), +.md-typeset .mm-catalog-wrap table td:nth-child(2) { width: 26%; } /* Preview */ +.md-typeset .mm-catalog-wrap table th:nth-child(3), +.md-typeset .mm-catalog-wrap table td:nth-child(3) { width: 30%; } /* Parameters */ +.md-typeset .mm-catalog-wrap table th:nth-child(4), +.md-typeset .mm-catalog-wrap table td:nth-child(4) { width: 18%; } /* Links */ + +/* fixed layout clips overflow by default — allow long param/description text to wrap. */ +.md-typeset .mm-catalog-wrap table td { word-wrap: break-word; overflow-wrap: anywhere; vertical-align: top; } + +/* Name column: title distinct from description (was one run-on line). The name is + bold and slightly larger; the description sits below in a muted colour with a + small gap, reading as title + subtitle. */ +.md-typeset .mm-catalog-wrap .mm-name { + font-weight: 700; + font-size: 0.95rem; + line-height: 1.5; + color: var(--md-typeset-a-color); /* accent purple, matching the param names */ +} +.md-typeset .mm-catalog-wrap .mm-desc { + color: var(--md-default-fg-color--light); + display: inline-block; + margin-top: 0.3rem; +} + +/* Parameters column: each param on its own line, compact (small gap, no left rule + — the earlier version made many-param effects like GameOfLife very tall). What + makes each param scannable is the NAME standing out, not the spacing. */ +.md-typeset .mm-catalog-wrap .mm-param { + display: block; + margin: 0 0 0.25rem 0; + line-height: 1.4; +} +.md-typeset .mm-catalog-wrap .mm-param:last-child { margin-bottom: 0; } + +/* The param NAME (a `code` chip in the source) is the anchor the eye scans for — + make it stand out: accent colour, bold, no chip background so it reads as a + highlighted term rather than a muted grey box blending into the prose. */ +.md-typeset .mm-catalog-wrap .mm-param code { + color: var(--md-typeset-a-color); /* the theme accent (purple) */ + background: transparent; + font-weight: 600; + font-size: 0.82em; + padding: 0; +} + +/* Control description (the prose after the em-dash) greyed, matching the muted + module description — so within a param, the name pops (purple) and its + explanation recedes (grey), consistent with name-vs-description elsewhere. */ +.md-typeset .mm-catalog-wrap .mm-pdesc { + color: var(--md-default-fg-color--light); +} + +/* Links column: grey the plain attribution prose (author, project, year). The + actual links (Tests, source .h, MoonLight) keep their accent colour — Material's + `a` rule is more specific than this, so only the surrounding text greys. */ +.md-typeset .mm-catalog-wrap .mm-links { + color: var(--md-default-fg-color--light); +} diff --git a/docs/install/favicon.png b/docs/assets/favicon.png similarity index 100% rename from docs/install/favicon.png rename to docs/assets/favicon.png diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index aac5ef01..290198d6 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -323,7 +323,7 @@ Compile-time answer already ships: `--firmware esp32-eth` excludes the WiFi stac ## UI -Forward-looking companion to the shipped UI spec, [moonmodules/core/ui.md](../moonmodules/core/ui.md). The live spec describes the UI as shipped; this file holds what is **not** in it yet: deferred items, open design questions for 1.0, and the gap analysis against projectMM v1. The backward-looking half (how v1/v2 actually worked, patterns consciously rejected, recorded quirks) lives in [history/v1-inventory.md](../history/v1-inventory.md). +Forward-looking companion to the shipped UI spec, [moonmodules/core/ui/ui.md](../moonmodules/core/ui/ui.md). The live spec describes the UI as shipped; this file holds what is **not** in it yet: deferred items, open design questions for 1.0, and the gap analysis against projectMM v1. The backward-looking half (how v1/v2 actually worked, patterns consciously rejected, recorded quirks) lives in [history/v1-inventory.md](../history/v1-inventory.md). ### Deferred to 1.x diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md index 7dd18e8c..051f766f 100644 --- a/docs/backlog/backlog-light.md +++ b/docs/backlog/backlog-light.md @@ -14,7 +14,7 @@ The LcdLedDriver (S3 LCD_CAM i80) and ParlioLedDriver (P4 Parlio) share ~245 of ### Classic ESP32 I2S 16-lane parallel LED driver (future) — beyond RMT's 8 channels -The **classic ESP32 has 8 RMT TX channels** (`platform_config.h`: "8 on classic ESP32, 4 on the S3 and P4"), so RMT covers up to 8 parallel outputs on classic ESP32 — e.g. the 8-output QuinLED Dig-Octa runs fine on `RmtLedDriver`. For **more than 8 lanes on classic ESP32**, the established trick drives the **I2S peripheral in LCD/parallel mode** (the hpwit [I2SClocklessLedDriver](https://github.com/hpwit/I2SClocklessLedDriver) / FastLED I2S lineage), clocking out up to **16 lanes** from one autonomous DMA transfer. This is the classic ESP32's high-lane-count path, distinct from the S3 (LCD_CAM → `LcdLedDriver`, plus the [1..8-pin LCD item](#18-pin-lcd-output-future--would-let-s3-default-to-lcd) above) and the P4 (Parlio). No catalog board needs it today (none exceeds 8 outputs), so no board's `planned` list points at it yet; it's the marker for a future ≥9-output classic-ESP32 board. Studied under *Industry standards, our own code* — carry the idea, write our own against the project architecture (host-testable encoder in `src/light/`, peripheral seam in `src/platform/esp32/`). **When it lands**, follow the per-chip driver-gating pattern now in `main.cpp` (each LED driver's `#include` + `registerType` is wrapped in `#if defined(CONFIG_SOC_<PERIPHERAL>_SUPPORTED)`, keyed off the SOC capability macro that backs its `platform_config.h` lane-count flag): the I2S driver gates on the relevant I2S/LCD SOC macro so it compiles + registers on classic ESP32 only, and adds an `i2sLanes` capability flag beside `rmtTxChannels`/`lcdLanes`/`parlioLanes`. Prior art: hpwit's I2SClockless lineage and FastLED's I2S driver; the same parallel-DMA lineage is already credited in [LcdLedDriver.md § Prior art](../moonmodules/light/drivers/LcdLedDriver.md#prior-art). +The **classic ESP32 has 8 RMT TX channels** (`platform_config.h`: "8 on classic ESP32, 4 on the S3 and P4"), so RMT covers up to 8 parallel outputs on classic ESP32 — e.g. the 8-output QuinLED Dig-Octa runs fine on `RmtLedDriver`. For **more than 8 lanes on classic ESP32**, the established trick drives the **I2S peripheral in LCD/parallel mode** (the hpwit [I2SClocklessLedDriver](https://github.com/hpwit/I2SClocklessLedDriver) / FastLED I2S lineage), clocking out up to **16 lanes** from one autonomous DMA transfer. This is the classic ESP32's high-lane-count path, distinct from the S3 (LCD_CAM → `LcdLedDriver`, plus the [1..8-pin LCD item](#18-pin-lcd-output-future--would-let-s3-default-to-lcd) above) and the P4 (Parlio). No catalog board needs it today (none exceeds 8 outputs), so no board's `planned` list points at it yet; it's the marker for a future ≥9-output classic-ESP32 board. Studied under *Industry standards, our own code* — carry the idea, write our own against the project architecture (host-testable encoder in `src/light/`, peripheral seam in `src/platform/esp32/`). **When it lands**, follow the per-chip driver-gating pattern now in `main.cpp` (each LED driver's `#include` + `registerType` is wrapped in `#if defined(CONFIG_SOC_<PERIPHERAL>_SUPPORTED)`, keyed off the SOC capability macro that backs its `platform_config.h` lane-count flag): the I2S driver gates on the relevant I2S/LCD SOC macro so it compiles + registers on classic ESP32 only, and adds an `i2sLanes` capability flag beside `rmtTxChannels`/`lcdLanes`/`parlioLanes`. Prior art: hpwit's I2SClockless lineage and FastLED's I2S driver; the same parallel-DMA lineage is already credited in [the LED-output card](../moonmodules/light/drivers/drivers.md#lcdled). ## Sensors and audio-reactive input diff --git a/docs/backlog/docs-system-overhaul.md b/docs/backlog/docs-system-overhaul.md new file mode 100644 index 00000000..9de1c713 --- /dev/null +++ b/docs/backlog/docs-system-overhaul.md @@ -0,0 +1,104 @@ +# Documentation system overhaul — investigation + phased proposal + +Forward-looking design study (per CLAUDE.md, `docs/backlog/` is exempt from present-tense). Written in response to the product-owner brief: the docs are ~259K words across 19.5K lines of `.md`; end users can't navigate them, a small change touches source + tests + docs at once, and technical detail lives in `.md` rather than in the code. Goals: **no duplication**, a **top-down end-user path**, **easy developer drill-down into `.h`/`.cpp`**, and **tests visible to end users** — all hosted on GitHub Pages. + +Follows the *Refactor for simplicity* process rule: alternatives enumerated, gains/losses named, leanest option recommended, presented as a proposal — nothing moves until the PO picks. + +## What we have today (measured) + +- **~259K words / 19.5K lines** of `.md`. Biggest buckets: `docs/history/` (97K words, incl. 47 plan files), `docs/backlog/` (57K), `docs/moonmodules/` (38K), `docs/tests/` (25K, **auto-generated**), top-level `docs/` (29K). +- **GitHub Pages today publishes only the web installer** (`docs/install/`), *not the docs*. So the docs are read as raw `.md` on github.com — no nav, no search, no landing page. This is the root of "impossible for end users to read": **there is no doc *site* at all yet.** +- **One generation loop already works and proves the model:** `scripts/docs/generate_test_docs.py` reads test metadata (`// @module` tags in `test/unit/*.cpp`, JSON fields in `test/scenarios/*.json`) and emits `docs/tests/{unit,scenario}-tests.md`. The same parser (`_test_metadata.py`) feeds MoonDeck's `/api/tests` UI. Source of truth = the test file. This is the pattern to extend, not replace. +- **Duplication hotspots** — each fact lives in N places, changing one forces the others: + + | Fact | Lives in | Sync today | + |---|---|---| + | Control name (`"sparking"`) | `.h` (definition) + `.md` spec + test code | `check_specs.py` checks *presence* in `.md`, not accuracy | + | Control range/default (8000/16000/22050/44100) | `.h` array + `.md` prose | none — hand-copied | + | Author/attribution | `.h` `// Author:` + `.md` `Origin:` (40+ effects, two formats) | none | + | Module name (`FireEffect`) | class + `registerType(...)` string + test `@module` + `.md` anchor + `deviceModels.json` | `check_specs`/`check_devices` partial | + | Architectural fact (buffer persists; AudioModule respects `enabled`) | `.h` comment + module `.md` + `architecture.md` | none | + +- **No structured doc-comments in source.** `.h`/`.cpp` carry rich *inline `//` rationale* but no Doxygen/`///` API blocks. So "move technical detail into the code" is a real move, not a relabel. + +## The core insight + +Two different problems wear the same "docs too big" coat, and they have **opposite** fixes: + +1. **Navigation / readability** (end users). Fix = a rendered site with a top-down nav and search. Additive: nothing is deleted, the raw `.md` gets a front door. **Cheap, immediate, low-risk.** +2. **Duplication** (developers). Fix = single-source each fact and *generate* the copies (or include them). Subtractive and invasive: it changes where facts live and how they're authored. **Do incrementally, proven per fact-type before rollout.** + +Phase them in that order: the site is the quick win that makes everything else visible; de-duplication is the slow structural win. Do **not** bundle them — a big-bang "new site + moved all facts into code" is the kind of change *Refactor for simplicity* exists to stop. + +## The tools (all GitHub-Pages-native) + +| Tool | Role | Why it fits here | +|---|---|---| +| **Material for MkDocs** | The site: nav tree, instant search, versioning. | PO-named default; the recognised standard (WLED-adjacent projects, FastLED-ecosystem, thousands of firmware projects use it). Ships to Pages via one CI job. `nav:` in `mkdocs.yml` *is* the top-down structure. | +| **`pymdownx.snippets`** (`--8<--`) | De-dup mechanism #1: pull real source lines into a doc. | Built into Material. A spec can embed the actual `controls_.addX(...)` block or an author comment *from the `.h`* — one source of truth, rendered in two places. No new tool. | +| **`mkdocs-gen-files` / `mkdocs-macros`** | De-dup mechanism #2: generate whole doc pages from data at build time. | Standard MkDocs plugins. Lets `generate_test_docs.py`-style generation run *inside* the site build instead of committing generated `.md`. Kills the "forgot to regenerate" drift. | +| **Doxide** | Developer drill-down: parse `.h`/`.cpp` with Tree-sitter → **Markdown** → rendered *in the same Material site*. | This is the ".h/.cpp viewer layered on top" the PO asked for. Unlike classic Doxygen (1998-era HTML, a separate ugly site), Doxide emits Markdown that lives in *our* nav, *our* search, *our* theme — developers drill from a module's user page straight into its annotated source, one site. | + +Rejected: **classic Doxygen HTML** (separate site, dated UI, not integrated), **Sphinx/Breathe** (Python-doc-shaped, heavier, C++ via Breathe is awkward), **Docusaurus/VitePress** (Node toolchain, no C++ story), **mdBook** (great but no C++ integration, weaker search). Material+Doxide is the least bespoke combination that hits all four goals on Pages. + +## Phased transition + +### Phase 0 — Stand up the site (no content changes) — *now, ~half a day* +- Add `mkdocs.yml` with Material, `pymdownx.snippets`, instant search. Author a `nav:` tree that imposes the **top-down end-user order** (see below) over the *existing* files — no file moves, no rewrites. +- Add a `build-docs` CI job that `mkdocs build`s and publishes to Pages **alongside** the installer (installer already owns `/install/`; docs take `/` or `/docs/`). +- Add a **landing page** (`docs/index.md`) — the front door end users currently lack: "what is this → install → first light → effects → drill down." +- **Outcome:** every existing word is now navigable + searchable, zero duplication introduced, zero risk. This alone solves the *"impossible for end users to read"* complaint. + +### Phase 1 — Restructure nav for the two audiences — *DONE (Phase 0's nav already delivered it)* +- Split the nav (not the files, yet) into **User guide** (README intro, getting-started, effect catalog, per-board install) and **Developer/Reference** (architecture, coding-standards, module specs, generated tests, source-drill). `history/` + `backlog/` stay out of the published nav (internal). +- Move the *most* end-user-hostile prose (deep architecture) below a "Developer" fold so a user's top-down path never hits it unless they drill. +- **Outcome:** the "top-to-down, easy navigate" structure, still additive. + +### Phase 2 — Fold generated tests into the site + surface them to users — *DONE (scripts/docs/mkdocs_hooks.py)* + +*As shipped: the two inventory pages are generated at build time (never committed). Each effect/modifier keeps a compact card (heading, GIF, params, source link) with a one-line `[Tests]` link into its inventory section — an early attempt to expand that link into the full inline case list bloated the cards and was reverted; tests are a link, not a dump.* +- Run the existing test-doc generation *at site-build time* via `mkdocs-gen-files` (stop committing `docs/tests/*.md` — the 25K generated words leave the repo, generated fresh each build). Kills the "forgot to regenerate" drift class entirely. +- On **each effect/module user page**, auto-embed "Tests proving this works: …" from the same test metadata — so an end user reading about *Fire* sees the tests that pin it. This is the PO's *"github issues will be solved adding a new test to proof it, this should be visible to end users."* The link from issue → test → visible-on-the-module-page becomes the norm. +- **Outcome:** tests visible to users; 25K words of committed generation deleted (subtraction). + +### Phase 3 — De-duplicate facts, one fact-type at a time — *DONE, but pivoted (see below)* + +*As shipped: the snippet-include premise was wrong — `.h` and `.md` hold the same fact in different forms (code vs prose) for two audiences, which `--8<--` can't bridge. The real duplication was narrower: control **ranges** (~50) and author **URLs** (~51) restated in both places. PO chose **validate, not generate** — `check_specs.py` now flags when a doc's stated range/URL drifts from the `.h` (block-scoped on catalog pages; tolerant of human range spellings), pinned by `test/python/test_check_specs_drift.py`. Control *names* and architectural facts were confirmed NOT duplication (audience-aware) and left alone. Original text below for the record.* +Prove each on **one module**, then sweep. Order by leverage: +1. **Author/attribution** → single source in the `.h`, `--8<--` snippet-include into the `.md`. Deletes 40+ hand-maintained `Origin:` copies. (Lowest risk: it's a comment.) +2. **Control names + ranges/defaults** → the `.h` `controls_.addX(...)` block is already the source of truth; either snippet-include it, or extend `check_specs.py` into a *generator* that emits the control table into the spec. Deletes the hand-copied range prose; upgrades `check_specs` from "checks presence" to "owns the table." +3. **Cross-doc architectural facts** → pick the one true home (usually `architecture.md` or the `.h`), replace the other copies with a link/anchor per *Document a thing once, reference it generically* (already a principle — this enforces it mechanically). +- **Outcome:** "change a small thing, many files change" shrinks to "change the source, the copies regenerate." + +### Phase 4 — Developer drill-down into source — *split: 4a snippets DONE, 4b Doxide next commit* + +*As shipped (4a): Doxide can't use `uv` (a from-source C++ binary) and our 139 `.h` files use plain `//` comments, not Doxygen style — so it's heavy on CI + comment-conversion. PO wants Doxide eventually but chose to try `pymdownx.snippets` first (uv-native, no new tool): `--8<--` now embeds the real `ImprovFrameType` enum + Preview wire-format from the source `.h` into their docs — the source IS the doc, no drift. Doxide (4b) is spec'd for its own next commit (Plan-20260702 Docs Phase 4a), informed by what 4a showed about how our `//` comments render. Original Doxide text below.* +- Add **Doxide**: annotate `.h` public API with its lightweight comment style, generate Markdown into the Developer section of the site. Start with `core/` (the stable base), then light domain. +- This is where "technical details in the code, not `.md`" lands: the module `.md` shrinks to *wire contracts + cross-wiring + prior art* (its already-stated job per CLAUDE.md), and the *API-level* detail is generated from annotated source. +- **Outcome:** developers drill user-page → spec → annotated source, one site, search across all of it. + +## Alternatives considered (for the record) + +- **A — Site only, never de-dup.** Solves navigation, ignores the duplication complaint. Rejected: PO's *main* goal is no-duplication. +- **B — De-dup first, site later.** Restructures authoring before anyone can see the payoff; high risk, no visible win for weeks. Rejected: wrong order. +- **C — Big-bang Material + Doxide + full de-dup in one branch.** Everything at once. Rejected by *Refactor for simplicity*: unreviewable, all-or-nothing. +- **D (recommended) — Phased: site now (additive, safe), de-dup incrementally (proven per fact-type), Doxide last.** Each phase ships a visible win and is independently revertible. + +## Decisions locked (PO, 2026-07) + +- **Scope:** Phase 0 approved (stand up the site). Phases 1–4 each need a separate go-ahead. +- **Site URL:** docs at Pages root `/`; installer stays at `/install/`. The site is assembled in CI into a throwaway dir; the repo addition is `mkdocs.yml`. +- **`docs/` de-overloading (landed with Phase 0):** `docs/` had held three unlike things. The standalone web installer moved out to a top-level **`web-installer/`** (it's an app, not docs; deployed URL unchanged at `/install/`). The transient `history/` + `backlog/` stay in `docs/` but excluded from the site — they're compaction-bound, so relocating them is discarded churn. Result: `docs/` = published doc-site source + transient internal notes (excluded). +- **`scripts/` → `moondeck/`:** approved, but deferred to its own next commit (see `rename-scripts-to-moondeck.md`). +- **Doxide comment style:** approved as the source-annotation convention for Phase 4 (Doxygen-family, recognised standard). + +## Open questions for the PO + +1. **Site URL layout:** docs at Pages root `/` with installer under `/install/` (docs are the front door), or docs under `/docs/` keeping `/` as the installer landing? (Recommend: docs at `/`, a prominent "Install" button routing to `/install/`.) +2. **Doxide comment style** is a new per-`.h` convention — acceptable as a *recognised* standard (it's Doxygen-family), or do we want inline `//` to stay the only comment form? (Affects Phase 4 only.) +3. **Scope of Phase 0 approval:** stand up the site now (safe, additive) and defer 1–4 for separate go-aheads, or approve the whole arc? + +## Source + +- Investigation basis: `scripts/docs/generate_test_docs.py`, `scripts/check/check_specs.py`, `.github/workflows/release.yml` (Pages deploy), the `docs/` tree. +- Prior art / tooling: [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/), [pymdownx snippets](https://facelessuser.github.io/pymdown-extensions/extensions/snippets/), [Doxide](https://github.com/lawmurray/doxide). diff --git a/docs/backlog/rename-scripts-to-moondeck.md b/docs/backlog/rename-scripts-to-moondeck.md new file mode 100644 index 00000000..03c676ce --- /dev/null +++ b/docs/backlog/rename-scripts-to-moondeck.md @@ -0,0 +1,49 @@ +# Rename top-level `scripts/` → `moondeck/` + +Forward-looking (backlog is exempt from present-tense). Decided by the PO on 2026-07-02, to be done as its **own separate commit** in the next cycle — deliberately NOT folded into the docs-site / `web-installer/` commit (that one is scoped to the `docs/` separation; this is a larger orthogonal sweep and stays isolated for a clean, revertible diff). + +## Why + +Consistency with the `web-installer/` move: top-level folders should name what they hold. MoonDeck is the human-facing dev console (`uv run moondeck.py`); the folder is its home. + +> Note (agent pushback recorded, PO overrode): `scripts/` is the more *recognizable* convention and it holds more than MoonDeck (`build/`, `check/`, `docs/`, `run/` groups the CLI gates invoke directly — see CLAUDE.md § Build). The rename narrows an accurate name. PO's call stands; captured here so the trade-off is on record, not to relitigate. + +## Scope (measured) + +~77 files / ~376 occurrences of the string `scripts/`. Load-bearing categories: + +- **CLAUDE.md** — every gate command (`uv run scripts/check/…`, `scripts/build/…`, `scripts/docs/…`), the Build section, the `scripts/**` commit-gate triggers. +- **Both CI workflows** — `.github/workflows/{release,test}.yml`: `run:` steps, `paths:` filters (`scripts/**`, `scripts/build/**`). +- **CMake** — `find_program(UV…)` + `execute_process` / `add_custom_command` that shell into `scripts/…`, and `src/ui/embed_ui.cmake`. +- **`moondeck_config.json`** — its own `"script": "<group>/<name>.py"` entries (relative to the scripts dir; check whether the loader prepends `scripts/` or the config is already relative). +- **Cross-references** in `scripts/MoonDeck.md`, `docs/building.md`, module specs, and every script's own docstring/help text. +- **The deferred/allow lists** in `.claude/settings.local*.json` (gitignored live file re-accumulates; the tracked `.cleaned.json` needs updating). + +## The gotcha that WILL bite (learned from the `web-installer/` sweep) + +A naive `s{scripts/}{moondeck/}` regex **misses split-component path construction**. The `web-installer` sweep left three JS test files broken because they built the path as `join(ROOT, "docs", "install", …)` — separate string args, not the literal `docs/install/`. The equivalent here: + +- **Python:** `Path(...) / "scripts" / ...`, `os.path.join(ROOT, "scripts", ...)`. +- **JS:** `join(ROOT, "scripts", ...)`. +- **CMake:** `${CMAKE_SOURCE_DIR}/scripts` and `"${CMAKE_SOURCE_DIR}" "scripts"` forms. + +Grep for **all** of these split forms (`"scripts"`, `'scripts'`, `/ "scripts"`, `, "scripts"`) BEFORE running the literal-path replace, or things break silently and only a test run catches them. + +Also protect against false positives: any identifier or word containing `scripts` that is NOT the folder (e.g. `livescripts`, `ESPLiveScript`, `description`, `subscripts`) — anchor the replace to `scripts/` with trailing slash and to the split-string forms, never a bare substring. + +## Verification (before the commit lands) + +- All gates green: `check_specs`, `check_devices`, `check_firmwares`, `ctest`, scenarios, `uv run --with pytest … test/python`, `node --test test/js/**`. +- Desktop + at least one ESP32 build (CMake reaches the renamed scripts via `find_program`/`execute_process`). +- MoonDeck starts and loads its config (`moondeck_config.json` script paths resolve). +- Docs site builds (`uv run <newpath>/docs/build_docs.py`). +- The gitignored `.claude/settings.local.json` allow-list entries pointing at `scripts/…` still match (they re-accumulate; the tracked `.cleaned.json` is the baseline to update). + +## Decision + +- **Separate commit, next cycle.** Not in the docs-site commit. +- Delete this backlog note once the rename ships (per *Mandatory subtraction* — the git commit is the record). + +## Source + +- Basis: the reference sweep done for the `web-installer/` move (this session); `git grep scripts/` for the live count. diff --git a/docs/building.md b/docs/building.md index 8b9d324f..c7b2c373 100644 --- a/docs/building.md +++ b/docs/building.md @@ -186,7 +186,7 @@ Tracked in [backlog](backlog/README.md). `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 product (deviceModel) is a separate concept — see [architecture.md § Firmware vs deviceModel vs board](architecture.md#firmware-vs-devicemodel-vs-board).) `build_esp32.py --help` lists the full set. -The canonical list is the **`FIRMWARES` dict** in [`scripts/build/build_esp32.py`](../scripts/build/build_esp32.py) — the single source of truth, carrying each variant's `chip`, sdkconfig `fragments`, `eth_only`, `ships`, and `description`. Its machine-readable projection is [`docs/install/firmwares.json`](install/firmwares.json) (generated by `generate_firmwares.py`, drift-guarded by `check_firmwares.py`), which the CI release matrix, the ESP Web Tools manifest loops, and MoonDeck all read — so the list lives in exactly one place. `esp32p4-eth-wifi` has `ships: false` (its C6-slave Kconfig isn't reproducible in CI yet), so it builds from the CLI but stays out of the release matrix. +The canonical list is the **`FIRMWARES` dict** in [`scripts/build/build_esp32.py`](../scripts/build/build_esp32.py) — the single source of truth, carrying each variant's `chip`, sdkconfig `fragments`, `eth_only`, `ships`, and `description`. Its machine-readable projection is [`web-installer/firmwares.json`](../web-installer/firmwares.json) (generated by `generate_firmwares.py`, drift-guarded by `check_firmwares.py`), which the CI release matrix, the ESP Web Tools manifest loops, and MoonDeck all read — so the list lives in exactly one place. `esp32p4-eth-wifi` has `ships: false` (its C6-slave Kconfig isn't reproducible in CI yet), so it builds from the CLI but stays out of the release matrix. 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; the default `esp32` keeps both stacks compiled in and uses the runtime cascade in `NetworkModule` (Ethernet first, WiFi fallback when no PHY responds). diff --git a/docs/coding-standards.md b/docs/coding-standards.md index 711ee589..871144d6 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -105,3 +105,33 @@ All targets build warnings-as-errors: `-Wall -Wextra -Werror` on Clang/GCC (macO - **Pre-push.** Opus reviewer agent over the push range, scoped to domain boundary, unnecessary abstractions, duplicated patterns, hot-path violations, spec conformance, bloat, platform boundary. - **PR merge.** Plan reconciliation, documentation sync, live perf snapshot, permission review, README refresh — applicability-gated, see CLAUDE.md. - **CI** — the same set, mandatory on every PR push. Exact CI configuration is set up when the repository's pipeline lands. + +## Documentation model + +Where each kind of fact lives. The guiding rule: **document a thing once, in the place closest to the thing, and generate or link the rest** — a fact the source states is never re-typed in prose. Every module has exactly **two reader surfaces**: a hand-written **summary page** (end-user, a table) and a **generated technical page** (from the `.h`). This is the docs.rs / Sphinx-autodoc / Doxygen split — a hand-written guide over a generated API reference — a pattern a web/systems contributor recognises on sight. + +### The tree mirrors `src/` + +[Everything is a MoonModule](architecture.md#moonmodules); `docs/moonmodules/` mirrors `src/`, a **`core/`** subtree and a **`light/`** subtree. Within each, a module is either **UI** (user-facing and configurable — the core services like Network/System/FileSystem, and the light **catalog modules**: layouts, effects, modifiers, drivers) or **supporting** (the machinery UI modules lean on — `Control`, `Scheduler`, `Layer`, `Buffer`, the `*Base` classes). [Peripherals](architecture.md#peripherals) are UI modules that bridge to hardware (a driver, a sensor). + +### The two surfaces + +1. **Summary page (hand-written, end-user).** One `.md` per module *group*, a 4-column table — **name + description · gif/image · controls · links** (per module: tests · its technical page · attribution · anchors to any extra prose). One row per module, authored as `### ` prose blocks that a build-time hook ([`scripts/docs/mkdocs_hooks.py`](../scripts/docs/mkdocs_hooks.py)) renders as the table. Catalog controls live here because a catalog module's user surface is its runtime `controls_.add(...)` calls, which no static tool sees. Each group is nested in its own subfolder, consistent with the catalog: + - `light/{effects,modifiers,layouts,drivers}/<name>.md` — the four light-catalog pages (may later split, e.g. `effects_wled.md` / `effects_moonmodules.md`). + - `core/ui/`, `core/supporting/`, `light/supporting/` — the core-UI, core-supporting, and light-supporting summary pages. + + Cross-file design rationale that no single `.h` owns (module interactions, buffer-lifecycle coupling) is a prose section beneath a summary page's table — the only home for it, so a module needs no page of its own. + +2. **Technical page (generated).** `docs/moonmodules/{core,light}/moxygen/<Module>.md`, produced from the `.h` by [`scripts/docs/gen_api.py`](../scripts/docs/gen_api.py): **Doxygen** (the de-facto-standard C++ parser) emits XML, **moxygen** renders Markdown through a custom Handlebars template ([`scripts/docs/moxygen-templates/`](../scripts/docs/moxygen-templates/)). Layout is controlled only through Doxygen config + that template — moxygen's own levers, the output is used verbatim, never post-processed. Each page carries the module's **class, variables, and members** from their `///` comments and links to its `.h`; a summary page's per-module link points here. + + The pages are **gitignored, regenerated on build** (flipping to committed-and-drift-gated is a one-line `.gitignore` change plus a gate like `check_firmwares.py`, if PR-review of the generated output ever earns it). Doxygen (a brew/apt binary) and moxygen (via npx) are the one justified non-uv dependency, like ESP-IDF's Python (see CLAUDE.md); absent locally the pages skip and the rest of the site builds, present in CI they render. + +### `///` comments are the single home for technical detail + +A module's per-entity detail — the module description, each variable, each member — is a `///` / `///<` comment in the `.h`. It generates into the technical page and shows on IDE hover. Two gotchas: a bare `<tag>` in a comment renders as a live HTML element and swallows the rest of the page (wrap any `<…>` in backticks), and Doxygen's `JAVADOC_AUTOBRIEF` ends the brief at the first `.` (write "such as", not "e.g."). Where a comment would hand-copy a wire format / enum / constant, embed the real source with a `--8<--` snippet instead (`// --8<-- [start:name]`). + +A module's story therefore lives in exactly two places: its `.h` (technical, generated) and its group summary row (end-user). Prior-art credit points at *other* projects learned from (FastLED, WLED, MoonLight, datasheets); superseded internal prototypes are not linked. + +### Test inventories: their own generator, not moxygen + +`docs/tests/*.md` is generated by [`scripts/docs/generate_test_docs.py`](../scripts/docs/generate_test_docs.py), not moxygen. Unit tests are `TEST_CASE("…")` macros tagged with `// @module` and per-case `//` descriptions — a convention Doxygen documents nothing of (it parses C++ *entities*, not macro string literals) — and scenario tests are JSON, which moxygen cannot read. moxygen is for the `.h` module pages; the test generator owns the test pages. diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index fe7077e7..4b908f6b 100644 --- a/docs/gettingstarted.md +++ b/docs/gettingstarted.md @@ -183,8 +183,8 @@ network — lives here as its own module, and we're adding more all the time. ![The System module](assets/gettingstarted/02-05-UI-System.png) -> [SystemModule](moonmodules/core/SystemModule.md) · -> [AudioModule](moonmodules/core/AudioModule.md) +> [SystemModule](moonmodules/core/ui/ui.md) · +> [AudioModule](moonmodules/core/ui/ui.md) **Firmware** — which build you're running, and where you update it. The **Install** button here does an over-the-air update straight from the device — no @@ -192,7 +192,7 @@ USB cable needed once it's on your network. ![The Firmware module](assets/gettingstarted/02-06-UI-Firmware.png) -> [FirmwareUpdateModule](moonmodules/core/FirmwareUpdateModule.md) +> [FirmwareUpdateModule](moonmodules/core/ui/ui.md) **Network** — your connection: WiFi or Ethernet, signal strength, and the address others reach it at. The **Devices** section underneath finds other @@ -201,8 +201,8 @@ other. ![The Network module](assets/gettingstarted/02-07-UI-Network.png) -> [NetworkModule](moonmodules/core/NetworkModule.md) · -> [DevicesModule](moonmodules/core/DevicesModule.md) +> [NetworkModule](moonmodules/core/ui/ui.md) · +> [DevicesModule](moonmodules/core/ui/ui.md) > **Lights are just one use.** Everything above — the modules, the live controls, the > 3D view, the web UI, the networking — is a general-purpose engine that knows nothing @@ -224,7 +224,7 @@ on **serpentine** if your strip zig-zags back and forth. ![The Layouts module](assets/gettingstarted/02-08-UI-Layouts.png) -> [Layouts](moonmodules/light/Layouts.md) +> [Layouts](moonmodules/light/supporting/supporting.md) **Layers** — what plays on the lights. Add an **effect** (a moving pattern), stack several to blend them, and reshape them with **modifiers** (mirror, rotate, and @@ -233,7 +233,7 @@ tweak live. ![The Layers module](assets/gettingstarted/02-09-UI-Layers.png) -> [Layers](moonmodules/light/Layers.md) · [Layer](moonmodules/light/Layer.md) +> [Layers](moonmodules/light/supporting/supporting.md) · [Layer](moonmodules/light/supporting/supporting.md) **Drivers** — where the colours go. Set overall **brightness** and colour order, then add an output: real LED strips on a pin, or send the frame over the network @@ -241,7 +241,7 @@ then add an output: real LED strips on a pin, or send the frame over the network ![The Drivers module](assets/gettingstarted/02-10-UI-Drivers.png) -> [Drivers](moonmodules/light/Drivers.md) · +> [Drivers](moonmodules/light/supporting/supporting.md) · > [NetworkSendDriver](moonmodules/light/drivers/NetworkSendDriver.md) That's the whole picture: **layout → layers → drivers**, previewed in 3D, all diff --git a/docs/history/decisions.md b/docs/history/decisions.md index 2187016d..3e609584 100644 --- a/docs/history/decisions.md +++ b/docs/history/decisions.md @@ -645,7 +645,7 @@ The first audio-reactive capability: `AudioModule` (a SystemModule Peripheral) r **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. +**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/moxygen/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 @@ -791,3 +791,15 @@ The DevicesModule refactor first reached for a UDP beacon, then swung to "mDNS i - **A plugin seam makes "interop" our own industry-standard hook-in point.** Foreign systems hook in as `DevicePlugin`s (the adapter pattern), not hardcoded branches — one file per ecosystem, no core edit. The discovery half ships now (projectMM + WLED); the command half is a reserved extension (*concrete first, abstract later*). The seam allows **hub-shaped** plugins (Hue: a bridge → child resources + auth), not just flat devices — designing that room in now (without building it) lets the harder tiers land additively. The differentiator: one UI across WLED + ESPHome + Hue, incrementally. **A blocking socket op on the single render loop freezes EVERYTHING — and an intermittent freeze hides as the wrong cause.** The desktop web UI went "slow like a cow in the mud" and FPS pinned at ~18 regardless of grid size. The HTTP/WebSocket server runs *inside* the desktop render loop (`HttpServerModule::loop20ms`), and the accepted client socket carried a **2 s `SO_RCVTIMEO`**: whenever a request's bytes hadn't landed the instant `accept()` returned (the GET line a TCP segment behind the handshake), `recv()` **blocked the whole loop up to 2 s**. The two symptoms were one bug — a 2 s stall per unlucky request (the sluggishness) and a fixed ~50 ms-per-connection drag that capped the tick at ~18 FPS (the "FPS independent of grid" tell, while the Noise effect itself measured 222 FPS — proof the cost was non-render). **Fix:** the accepted socket is persistently non-blocking, `read()` returns -1 immediately when nothing's pending, and `writeSome` stops toggling it back to blocking; ~2 s → ~13 ms, FPS tracks real render speed. Lessons: (1) **a single-threaded loop that services I/O must never make a blocking call** — a timeout isn't a fix, it's the size of the freeze; non-blocking + poll is the only safe shape. (2) **"FPS constant across workload" means a fixed non-workload cost dominates** — compare the effect's own FPS to the tick FPS; a large gap points away from rendering. (3) An *intermittent* stall (timing race on byte arrival) masquerades as environment noise — it sent the debug down "zombie process / CPU busy-spin" dead ends before a profiler (thread stuck in the loop, not in render) + a "does plain curl also stall?" test isolated it. (4) It was **pre-existing on main**, surfaced only because this branch added a second per-tick socket read (the WS poll) that made the race fire more often — a latent single-loop-blocking bug waits for the load that exposes it. + +## Docs from source: generate the technical layer from `///` comments, keep a thin hand-written summary — and the traps that cost real time + +The docs overhaul first tried to *shrink* the per-module `.md` files, then inverted to **generate** them: the `.h` is the single home of technical content (`///` comments), a Doxygen→moxygen pipeline renders one page per module, and a hand-written summary page (a 4-column table) is the only prose that stays. The old `.md` files are absorbed into the headers and deleted. The recognisable prior art is docs.rs / Sphinx-autodoc / Doxygen — a generated API reference under a hand-written guide — which is why it survived the *Common patterns first* bar. The lessons are mostly the traps found building it: + +- **A per-file external-tool invocation doesn't scale — batch it.** The first cut ran `npx moxygen` once per header (132×); each `npx` cold-start is ~1 s, so a build took ~150 s and pinned `mkdocs serve` unusable. One Doxygen pass over all headers + one `moxygen --classes` call, recombined per-header via the XML `<location>` map, is ~7 s (22×). The rule: when a build step shells out to an external tool per item, the per-invocation startup cost *is* the build time — invoke once over the whole set, split the output, never loop the process. +- **Writing generated files into a watched dir is an infinite-rebuild loop — write only on change.** The generated `.md` live under `docs/`, which `mkdocs serve` watches. An unconditional write bumps the mtime every build → the watcher rebuilds → which regenerates → which writes again. The serve churned forever and every page load landed mid-rebuild (~7 s). Fix: compare content and skip the write when identical, so mtime stays put and the watcher goes quiet. Any generator that emits into a watched tree needs this. +- **An unbalanced backtick in a `///` comment silently deletes the class from the docs.** One header (`ImprovProvisioningModule`) generated *no* page. Cause: `` `connected: `ssid`` `` — nested/odd backticks — made Doxygen report "end of C comment inside a `` ` `` block" and stop associating the comment with the class, so `HIDE_UNDOC_CLASSES` dropped it. The symptom (a missing page) was three steps from the cause (a typo in prose). Sweep every enriched header for `WARN`-level Doxygen output, not just build success; a comment typo is a *content* bug that no compiler catches. +- **A bare `<tag>` in a doc comment renders as live HTML and eats the rest of the page.** `<textarea>` / `<value>` in a `///` became an unclosed HTML element that swallowed everything after it in the rendered table. Wrap every `<…>` in backticks. (Same class of bug as the backtick one: prose that's valid English but invalid markup, invisible until rendered.) +- **A generator that's *present but failing* must fail loud, not return empty.** `gen_api.generate()` first returned `{}` on any error — so a transient `npx` registry hiccup would ship a docs site with **zero** API pages and no red X. Split the two cases: toolchain *absent* → graceful `{}` (a contributor without Doxygen still builds the rest); toolchain *present but failing* (or under-producing vs a floor) → raise. Silent degradation in a generated artifact is worse than a hard failure, because nobody notices until a reader hits a 404. +- **Enrichment by parallel agents needs an adversarial read, not just a build.** Worker agents enriched 19 headers; all compiled and generated. But the pre-merge reviewer + CodeRabbit still found real content bugs a build can't catch: a future-tense roadmap sentence (violates present-tense), a core-affinity claim describing plumbing that doesn't exist (inherited verbatim from the old `.md` — the inaccuracy pre-dated the enrichment), and an intentional default-value change (`TextEffect hue 0→128`) riding in unremarked and untested. "Compiles + generates" is table stakes; prose accuracy needs a human/reviewer pass, and an *intentional* behaviour change (even a good one) needs a test pinning it. +- **A migration is only net-subtractive once the old thing is deleted — stage the deletion, but don't call it done early.** Moving the old `.md` to `archive/` (instead of deleting) kept them for cross-check but left the branch net-positive and shipped a temporary migration banner on every generated page. That's fine as an explicit *stage*, but the banner and archive are debt with a name and a removal plan, not a resting state. Mark such scaffolding "temporary / removed at Stage N" at every site so the cleanup is mechanical. diff --git a/docs/history/plans/Plan-20260702 - Docs Phase 1+2 (nav fold + tests in build).md b/docs/history/plans/Plan-20260702 - Docs Phase 1+2 (nav fold + tests in build).md new file mode 100644 index 00000000..fe78b674 --- /dev/null +++ b/docs/history/plans/Plan-20260702 - Docs Phase 1+2 (nav fold + tests in build).md @@ -0,0 +1,44 @@ +# Plan — Docs Phase 1 + Phase 2 (nav fold + generated tests in the build) + +Approved-pending. Continues the docs overhaul ([docs/backlog/docs-system-overhaul.md](../../backlog/docs-system-overhaul.md)) on the `next-iteration` branch, on top of the Phase 0 commit. Phases 1 and 2 land together (Phase 1 is mostly already done by Phase 0's nav, so it's a small finish folded into the higher-value Phase 2). + +## Phase 1 — finish the audience-split nav (small, mostly done) + +Phase 0's `nav:` already splits User guide vs Developer reference and excludes `history`/`backlog`. The remaining Phase-1 work: +- **Group the "Tests" nav section under the user-facing top level** (it currently sits between Effects and Developer reference — confirm that's the right spot for an end-user path; likely yes). +- **No file moves.** Phase 1 was always nav-only. If the current nav already reads top-down cleanly, Phase 1 is declared done with at most a label/ordering tweak — do NOT invent restructuring to justify the phase. + +## Phase 2 — fold test-doc generation into the build + surface tests per module + +Two deliverables, both leaning on existing infrastructure (`scripts/docs/_test_metadata.py` already parses tests and feeds both the doc generator and MoonDeck; `render_unit_tests()`/`render_scenarios()` in `generate_test_docs.py` are importable pure functions; `cases_for_module()`/`paths_for_module()` already return per-module test data). + +### 2a — generate the test pages at build time (stop committing them) +- Add **`mkdocs-gen-files`** (standard MkDocs plugin) with a small `scripts/docs/gen_pages.py` hook that, at `mkdocs build` time, calls the existing `render_unit_tests(collect_unit_files())` / `render_scenarios(collect_scenario_files())` and writes `tests/unit-tests.md` + `tests/scenario-tests.md` into MkDocs' virtual file tree. +- **Delete the committed `docs/tests/unit-tests.md` + `docs/tests/scenario-tests.md`** (~25K words leave the repo — pure subtraction). They regenerate fresh on every build. +- **Retire the commit-time drift gate** for these files: `generate_test_docs.py --check` (CLAUDE.md Event 1 context) and any CI drift check become unnecessary — the pages can't drift when they're built from source every time. Keep `generate_test_docs.py` itself (CLI still useful for a quick local look, and MoonDeck may reference it), but it no longer *writes into the repo* as the source of truth for the site. Decision to settle in review: keep the CLI writing to `docs/tests/` for non-site consumers, or make it print-only. Leaning: keep it writing (MoonDeck/CLI convenience) but drop the CI `--check` gate, and gitignore `docs/tests/*.md` so a stray local run doesn't dirty the tree. +- Add `mkdocs-gen-files` to `build_docs.py`'s inline PEP-723 deps and the CI docs build. + +### 2b — "Tests proving this works" on each module page +- A `mkdocs-gen-files` (or a macro via `mkdocs-macros-plugin`) step that, for each module page, injects a **Tests** section listing the unit cases + scenarios that exercise it, sourced from `cases_for_module(name)` / `paths_for_module(name)`. +- This **replaces the hand-authored `[Tests](../../../tests/unit-tests.md#foo)` links** in effects.md/modifiers.md — which were the brittle, drift-prone anchors we just fixed by hand. Auto-generated from metadata, they can't rot: a module with no test shows "no tests yet" (honest), a module with tests shows them. Delivers the PO's "a GitHub issue is solved by adding a test, visible to end users." +- Scope decision for review: inject into the *rendered* page only (via gen-files/macros, source `.md` stays clean) vs. write into the source `.md`. **Leaning: rendered-only** — keeps the source `.md` free of generated blocks (consistent with 2a's "generated, not committed" principle) and avoids a new drift class. + +## Why together, why now +- Phase 1 is too small to be its own change; it's nav polish that belongs with the next docs commit. +- Phase 2 is the highest value-to-effort remaining phase and is **subtraction** (deletes 25K committed words + a CI gate + the hand-authored test links). It builds directly on the 24 tests just added. +- Continues on `next-iteration` (PO's workflow: exercise the branch before merging). No dependency on Phase 0 being *merged* — only on its files, which are on the branch. + +## Files +- **New:** `scripts/docs/gen_pages.py` (the mkdocs-gen-files hook). +- **Edit:** `mkdocs.yml` (add `gen-files` [+ `macros` if 2b uses it] plugins; nav tweak), `scripts/docs/build_docs.py` (inline dep), `.gitignore` (`docs/tests/*.md`), `docs/moonmodules/light/effects/effects.md` + `modifiers/modifiers.md` (drop the hand `[Tests]` links, replaced by the injected section), CLAUDE.md (drop the test-doc `--check` gate note), `docs/testing.md` (describe build-time generation). +- **Delete:** `docs/tests/unit-tests.md`, `docs/tests/scenario-tests.md` (regenerated at build). + +## Verification +- `uv run scripts/docs/build_docs.py` builds; `tests/unit-tests.html` + `tests/scenario-tests.html` exist in `site/` (generated, not from committed source). +- Each effect/modifier page shows its Tests section; a no-test module shows "no tests yet" (none should, after the 24 additions). +- 0 broken anchors; the 254 intentional out-of-`docs/` source-link warnings unchanged (Phase-4 marker). +- ctest/scenarios/spec-check still green (Phase 2 touches no C++; the deleted `.md` were generated artifacts). +- MoonDeck's test view (which uses the same `_test_metadata.py`) still works — shared parser untouched. + +## Out of scope (later phases, separate go-ahead) +- Phase 3 (de-dup facts via snippet-includes), Phase 4 (Doxide source drill-down). diff --git a/docs/history/plans/Plan-20260702 - Docs Phase 3 (drift validation, not snippet de-dup).md b/docs/history/plans/Plan-20260702 - Docs Phase 3 (drift validation, not snippet de-dup).md new file mode 100644 index 00000000..b0d9df0a --- /dev/null +++ b/docs/history/plans/Plan-20260702 - Docs Phase 3 (drift validation, not snippet de-dup).md @@ -0,0 +1,57 @@ +# Plan — Docs Phase 3: drift validation (not snippet de-duplication) + +Approved-pending. Continues the docs overhaul ([docs/backlog/docs-system-overhaul.md](../../backlog/docs-system-overhaul.md)) on `next-iteration`, on top of the Phase 1+2 commit. + +## The pivot: the backlog's premise was wrong + +Phase 3 in the backlog assumed `pymdownx.snippets` (`--8<--`) could single-source facts by pulling them from the `.h` into the `.md`. **Investigation shows it can't** — snippets pull text *verbatim*, and the `.h` / `.md` don't hold the same *text*, they hold the same *fact in two forms for two audiences*: + +- `.h`: `controls_.addUint8("cooling", cooling, 1, 255)` — code + machine range, for the compiler/developer. +- `.md`: `` `cooling` — how fast heat dissipates… `` — human prose, for the reader. + +A snippet can't turn `addUint8(…,1,255)` into "how fast heat dissipates", and pulling the raw comment/code into prose drags `//` delimiters and code syntax that read wrong. So the naive "include the `.h`" plan is off the table. + +What the audit found instead — two buckets: + +- **Bucket A — true duplication, worth guarding:** control **numeric ranges** (~50, hand-copied `.h`→`.md`) and attribution **URLs** (~51, the same GitHub URL in `.h //Author:` and `.md Origin:`). No sync today; a range/URL can change in code and the doc silently drifts. +- **Bucket B — NOT duplication, leave alone:** control *names* (already validated by `check_specs`), and architectural facts (audience-aware restatements — `architecture.md` = the principle, module `.md` = this module's behaviour, `.h` = the implementation). De-duping these would *harm* readability; they're working as designed. + +## Decision (PO): validate, don't generate + +For both Bucket-A types: **catch drift with a gate, keep the prose hand-authored.** Not build-time generation (which mixes generated + authored content per line and is more fragile). The human prose stays readable; a check fails when the `.h` and `.md` disagree, so "change one, forget the other" is caught at commit instead of shipping. + +This is the pragmatic Phase 3: it solves the actual "change one thing, the other drifts" pain directly, at low risk, without forcing code-into-prose. + +## Deliverables + +Extend `scripts/check/check_specs.py` (the existing spec-drift gate — already parses `.h` control names and `.md` prose) with two new drift checks: + +### 3a — control-range drift +- Parse each control's numeric bounds from the `.h` (`addUint8("floor", floor, 0, 255)` → `floor: 0–255`; also `addInt`, `addUint16`, etc.). +- For each control that HAS a range in the `.h`, check the module's `.md` prose mentions BOTH bounds (as `0–255`, `0-255`, `(0…255)`, `1 to 8`, etc. — tolerate the common human spellings). +- **Warn (not hard-fail) on drift** — the range appears in the `.h` but not (or differently) in the `.md`. Reason: some controls legitimately don't restate the range in prose (a pin list, an enum); a hard-fail would force noise. A warning surfaces real drift without blocking. (Settle in review: warn vs a hard-fail with an opt-out list.) +- Report format: `MODULE.md — control 'floor' range 0–255 (from .h) not found in prose`. + +### 3b — attribution-URL drift +- Parse the URL(s) from each `.h` `// Author:` line. +- Check the same URL appears in the module's `.md` `Origin:` line (the `.md` wraps it in a markdown link; match on the bare URL substring). +- **Warn on drift** — a URL in the `.h` Author line absent from the `.md`. Report: `MODULE.md — author URL <url> (from .h) not in Origin line`. + +### Wiring +- These run inside `check_specs.py` (already a commit gate — CLAUDE.md Event 1, gate 1), so no new gate, no CI change. The catalog pages route their controls through the table hook, but the *source* prose the checks read is the `.md`, unchanged. +- `docs/testing.md`: document the two new drift checks under the spec-check description. + +## Explicitly OUT of scope (and why) +- **Snippet-including `.h` into `.md`** — can't bridge code↔prose (the pivot above). +- **Generating ranges into the doc** — PO chose validate-only; keeps prose readable. +- **Control names / architectural facts** — Bucket B, not duplication; de-dup would harm. +- **Phase 4 (Doxide)** — separate, later. + +## Verification +- `check_specs.py` runs clean on the current tree, OR reports genuine drift (which we then fix in the `.md` — surfacing existing drift is a *feature* of landing this). +- The new checks are unit-tested where practical (a `test/python` case feeding a synthetic `.h`+`.md` pair, asserting drift is caught) — matches the host-side test tier. +- No change to rendered docs (checks only); catalog tables/build unaffected. + +## Files +- **Edit:** `scripts/check/check_specs.py` (two drift checks), `docs/testing.md` (document them), any `.md` where the checks surface real drift (fix the prose). +- **Maybe new:** `test/python/test_check_specs_drift.py` (unit-test the new checks). diff --git a/docs/history/plans/Plan-20260702 - Docs Phase 4a (source snippets, Doxide next).md b/docs/history/plans/Plan-20260702 - Docs Phase 4a (source snippets, Doxide next).md new file mode 100644 index 00000000..6f73011a --- /dev/null +++ b/docs/history/plans/Plan-20260702 - Docs Phase 4a (source snippets, Doxide next).md @@ -0,0 +1,54 @@ +# Plan — Docs Phase 4a: source snippets now, Doxide next commit + +Approved-pending. Final phase of the docs overhaul ([docs/backlog/docs-system-overhaul.md](../../backlog/docs-system-overhaul.md)), on `next-iteration`, after Phase 3. + +## The split (PO decision) + +The goal is *the `.h`/`.cpp` files are the basis for documentation*. Two ways to get there: + +- **Doxide** (the backlog's Phase 4) renders full `.h` API docs into the site — but it's a **from-source C++ binary** (can't use `uv`; needs a CMake CI build), and our 139 `.h` files use **plain `//` prose**, not the Doxygen `///`/`@param` style Doxide reads richly. Heavy on two fronts. +- **`pymdownx.snippets`** (already enabled, uv-native) embeds **real source excerpts** into a doc — no new tool, no comment conversion. + +**PO wants Doxide eventually** (it's the fuller realisation of "source as the doc basis") but agrees to try snippets first. So: + +- **This commit — Phase 4a (snippets):** targeted source-embeds where the source genuinely *is* the spec. +- **Next commit — Phase 4b (Doxide):** given its own isolated commit to evaluate the from-source CI build + comment-style question on real output, abort-clean if it doesn't pay. + +**Why this order helps 4b** (the "does 2 make 3 easier?" question): snippets don't share CI/build plumbing with Doxide, but they *de-risk the decision* — we see how our plain-`//` comments read when surfaced, learn which `.h` regions are doc-worthy (the same regions Doxide would document), and decide the comment-conversion scope with eyes open instead of mid-build. Concrete-first. + +## Phase 4a deliverables (snippets) + +Pick **targets where the source is the authoritative spec and the doc currently hand-copies it** — so embedding removes a real drift risk (the same class Phase 3 guards, solved by making source *be* the doc). Not blanket; a handful of high-value cases: + +1. **Improv frame constants** — `ImprovProvisioningModule.md` hand-documents `0x01`/`0x03` frame types + the magic; embed the real `ImprovFrameType` enum + `kImprovMagic`/`kImprovMaxPayload` from `src/core/ImprovFrame.h` (mark the region `// --8<-- [start:frame-constants]` / `[end:...]`). +2. **Preview wire format** — `PreviewDriver.md` hand-writes `[0x03][count:u32]…` layouts; embed the real header-packing constants / the frame-type bytes from `src/light/drivers/PreviewDriver.h` where they're defined in code. +3. **(If clean) one more** — a control-enum or a small struct another spec hand-copies. Only where it reads well; stop at 2–3. + +Mechanism: add `// --8<-- [start:name]` / `[end:name]` markers around the source region (a comment the compiler ignores), and `--8<-- "src/…/File.h:name"` in the `.md`. `base_path` is already `$config_dir` (repo root), so `src/…` resolves. + +For each embed, **note what we learned** for 4b: did the plain `//` comment around it read well as doc, or would it need a richer comment? (This is the 4b input.) + +## Phase 4b (Doxide) — spec for the NEXT commit (not built now) + +Recorded so it's ready to evaluate: +- **Install:** Doxide is `git clone --recurse-submodules` + CMake build (C++ toolchain + LibYAML). CI options: build-and-cache the binary in the deploy-pages job, or vendor a prebuilt Linux binary. NOT `uv` — the one place the uv-everywhere rule bends (like the ESP-IDF Python exception; justify at the introduction site). +- **Comments:** decide from 4a's findings how much of the 139 `.h` to give Doxide-style comments. Likely **start with `core/`** (the stable base), a handful of files, evaluate the rendered output, then decide whether to sweep the light domain. Do NOT convert all 139 up front. +- **Integration:** Doxide emits Markdown → into a `Developer / Source` nav section of the existing Material site. The out-of-docs `../src/*.h` links (currently rewritten to GitHub by mkdocs_hooks.py) get repointed at the in-site Doxide pages where they exist. +- **Abort criteria:** if the CI build is too fragile or the rendered output doesn't beat the GitHub source links, drop it — 4a already delivers a lighter version of the goal. + +## Verification (4a) +- The embedded snippets render as fenced code in the doc; the values match the source (because they *are* the source). Editing the `.h` constant changes the rendered doc on next build — the single-source proof. +- `check_specs.py` + docs build stay green; no rendered-doc regressions elsewhere. +- The hand-copied `[0x03]…` prose that the snippet now covers is trimmed to a one-line pointer (the code block is the authority) — net subtraction where it applies. + +## Files (4a) +- **Edit:** the 2–3 source `.h` (add `--8<--` markers, comment-only), the 2–3 `.md` (replace the hand-copied block with the snippet include + trim surrounding prose), `docs/backlog/docs-system-overhaul.md` (mark Phase 4 split). +- **New:** this plan. + +## What 4a shipped + the 4b learnings (recorded 2026-07) + +Two targeted embeds landed: +1. **ImprovFrame.h `:frame-constants`** → ImprovProvisioningModule.md. Real `ImprovFrameType` enum + magic/version/payload constants. The source *is* the authority; renders as clean, syntax-highlighted C++. **This is the ideal case** — actual code constants an integrator needs verbatim. +2. **PreviewDriver.h `:wire-format`** → PreviewDriver.md. The `[0x03][count:u32]…` layout — but it lives in a `//` **comment block**, not code. It renders with the `//` prefixes intact (correct — it *is* source), acceptable in a `cpp` fence but visibly "a source comment," and the doc's richer behavioural prose stays around it. + +**The 4b (Doxide) input:** our plain `//` comments surface as literal commented source. For real code (enums/constants/structs) that's great. For prose-in-comments it's serviceable but shows why Doxide's structured comments would render cleaner — so **4b's comment-conversion effort is real but bounded**: the highest-value Doxide output is on the *code* (signatures, types, constants), which needs little/no comment change; the prose polish is optional. Start Doxide on `core/` where the types are the payload, evaluate, decide on the light domain. diff --git a/docs/history/plans/Plan-20260702 - Docs Phase 4b (Doxide pilot, attempted, abandoned).md b/docs/history/plans/Plan-20260702 - Docs Phase 4b (Doxide pilot, attempted, abandoned).md new file mode 100644 index 00000000..de256cf1 --- /dev/null +++ b/docs/history/plans/Plan-20260702 - Docs Phase 4b (Doxide pilot, attempted, abandoned).md @@ -0,0 +1,94 @@ +# Plan — Docs Phase 4b: Doxide pilot (evidence-first) + +Approved-pending. The autodoc phase of the docs overhaul ([docs/backlog/docs-system-overhaul.md](../../backlog/docs-system-overhaul.md)), on `next-iteration`. + +## What the Doxide evaluation actually showed (built + ran it, 2026-07) + +Before planning, Doxide was **built from source and run on real projectMM headers**. Findings — these ground the whole plan: + +1. **Doxide renders ONLY Doxygen-commented entities.** Given our plain `//` comments it produced an empty page. A `struct` with a `/** */` comment produced a clean per-class Markdown page (member table, Material admonitions) — genuinely nice, MkDocs-integrated output. **So Doxide needs the comment style converted to `/** */` / `///` first.** +2. **Its Tree-sitter parser choked on real C++20** on the first core file — `auto** newArr = new MoonModule*[n]` and `(c->*fn)()` both "parse error, will continue". Doxygen wouldn't. The own-parser fragility is real and showed up immediately. +3. **Adoption is near-zero** (author's own projects; single maintainer; ~1.3k stars). Low lock-in though — output is Markdown, so if Doxide dies we keep the `.md`. + +Conclusion: a full Doxide rollout (convert ~139 headers, risk parser failures across the codebase) is high-cost, high-risk, and premature. But the *output quality on clean, commented code is good*. So: **pilot it on a small, clean, comment-converted subset, in-site, and decide expansion on the real result** — not on reputation, not on a blanket sweep. + +## Pilot scope (deliberately minimal) + +**Convert 2–3 clean `core/` headers to Doxygen-style comments and render them into the site with Doxide. Nothing else.** No CI wiring yet (local build only for the pilot); no touching the 139; no C++20-heavy files. + +Candidate headers (verified clean of the constructs that choked the parser — no `auto**` / pointer-to-member): `ImprovFrame.h`, `AudioFrame.h`, `AudioLevel.h`, `Control.h`, `Scheduler.h`. Pick **2–3** that are genuinely useful to see as API docs (leaning `ImprovFrame.h` — already snippeted, so we compare snippet-vs-Doxide on the same file — plus `Control.h` or `Scheduler.h`). + +### Steps +1. **Comment conversion (bounded).** On the 2–3 pilot headers only, convert the class/struct/key-method `//` comments to `/** */` / `///` where it adds value. Keep it light — the parser reads the *structure*; a one-line `/** */` per public entity is enough to start. Note the diff size as the per-file cost signal for a future sweep. +2. **`doxide.yaml`** at repo root (or `docs/`) listing just the pilot files, `output:` into a `docs/api/` (or a virtual dir). +3. **Local render + eyeball.** Build with the local Doxide binary; inspect the generated `.md` in the Material site (drop it under a `Developer / Source (pilot)` nav entry). Compare to: (a) the GitHub source link, (b) the Phase-4a snippet, on `ImprovFrame.h`. +4. **Assess against criteria (below); write the verdict into this plan.** Decide: expand (and how far), or stop at pilot / drop. + +### NOT in the pilot +- CI integration (the from-source CMake build in the ubuntu deploy-pages job) — deferred until the pilot proves the output is worth it. If the pilot passes, CI wiring is its own step (build-and-cache the binary, or vendor a prebuilt Linux binary — the one justified uv-exception, like ESP-IDF). +- Converting more than 3 headers. +- Any C++20-heavy / template-heavy header (parser risk). + +## Decision criteria (write the verdict here after the pilot) +- **Output quality:** does the rendered API page beat the GitHub source link for a reader? (If not → drop; the links already work.) +- **Comment cost:** how invasive was the `/** */` conversion per file? Extrapolate to 139. +- **Parser robustness:** did it choke on any pilot header? How often would it across the codebase? +- **CI feasibility:** is the from-source build cacheable/reliable enough to add to deploy-pages? + +Only if all four clear does a wider rollout make sense. A likely outcome given the evaluation: **Doxide for `core/` (stable, clean, the API integrators care about), snippets for the rest** — a hybrid, not a blanket conversion. + +## Files (pilot) +- **Edit:** 2–3 `src/core/*.h` (comment conversion), `mkdocs.yml` (a pilot nav entry + the generated dir), maybe `.gitignore` (generated `docs/api/`). +- **New:** `doxide.yaml`, this plan. +- **Note:** the built Doxide binary lives at `/tmp/doxide-eval/build/doxide` for the local pilot (not committed). + +## VERDICT (pilot ran 2026-07, on ImprovFrame.h) + +Converted 4 comment blocks (`//` → `///`, **11 lines, no rewrite**), ran Doxide, staged its Markdown into our real Material site, screenshotted the rendered page. Against the four criteria: + +1. **Output quality — GOOD.** The `ImprovFrameParser` page renders natively in *our* Material site: our theme/nav/search/favicon, a member table, the method signature in a blue Material "function" admonition, the prose comment beneath. This is genuinely nicer than the GitHub source link for *reading the API surface* (types, signatures, what each does) — and it's in-site, the Phase-0 one-site vision realised. ✅ +2. **Comment cost — LOW per file, but multiply by 139.** 11 lines for one header, purely mechanical (`//`→`///` above each documented entity; inline `//` on members needs `///<`). Cheap per file, but a full sweep is still ~139 files of mechanical edits — bounded, not free. +3. **Parser robustness — MIXED (the real caveat).** Two failures found: (a) on the FIRST core file (MoonModule.h) the Tree-sitter parser errored on `auto** x = new T*[n]` and `(c->*fn)()` and skipped them — Doxygen wouldn't. (b) **Enum *values* and their inline `//` comments do NOT render** — ImprovFeedResult's four values + their meanings were dropped; only the enum's own comment shows. For an enum-heavy codebase that's a real gap (the values are often the point). Doxygen with `///<` trailing comments would show them. +4. **CI feasibility — deferred, but the binary built clean** (cmake + libyaml, ~1 min). Not yet wired; if adopted, build-and-cache in deploy-pages or vendor a prebuilt Linux binary (the one justified uv-exception). + +**Conclusion: Doxide is viable and the output is good, but with two known gaps (parser skips some C++20; enum values don't render).** The hybrid the plan predicted is confirmed the right shape: **use Doxide for the stable, clean, class/struct-shaped `core/` API where it renders well, and keep `pymdownx.snippets` for enum-value tables / wire-format constants / anything Doxide drops.** Not a blanket 139-file conversion. + +**Recommendation for the actual Phase-4b commit (next):** wire Doxide into CI for a curated `core/` file list (start ~5–8 clean headers), converting only those; add the `Developer / Source` nav section; keep the snippets for the enum/constant cases. Grow the file list as headers are touched, per the "existing em-dashes replaced as files are touched" incremental pattern — never a big-bang sweep. The pilot itself is reverted (the `///` on ImprovFrame.h can stay as harmless — it's valid and already improves the source-doc quality). + +### Second pilot: a CATALOG item (GameOfLifeEffect.h) — Doxide adds nothing here + +Ran Doxide on a catalog effect too. Result: **near-empty page, zero value, and it sharpens the scope boundary.** The generated `GameOfLifeEffect` page showed only the class name + class comment — no controls, no methods, no state. Three reasons, all structural: +1. Public members carry plain `//` (or no) comments → Doxide skips them. +2. The parser choked on `uint8_t backgroundColorR = 0, backgroundColorG = 0, …` — **comma-separated declarations on one line**, very common C++ — 4 parse errors, members skipped. +3. **The user-facing surface of a catalog effect is its CONTROLS, which are `controls_.addX(...)` *calls inside `onBuildControls()`* — a method body, not declarations. No static doc tool (Doxide or Doxygen) can see them.** Our own card generator reads those same calls and *is* the only thing that surfaces them. + +So for every catalog item the card already carries what a reader wants (description, controls+ranges, GIF, source link), and Doxide would produce a title-only page that duplicates the card and adds nothing — pure cost. + +**Sharpened boundary:** Doxide is for the **services & infrastructure** layer only — the class/struct/enum-shaped `core/` and light-*base* code whose declarations *are* the API (parsers, buffers, the module base, wire-format types). It is **NOT** for the catalog module types (effects/modifiers/layouts/drivers): those are documented by their cards (generated from the runtime `controls_.add` calls), which no static tool can replicate. This maps exactly onto the two-module-kinds distinction already in coding-standards.md § Documentation model. + +## Third + fourth pilots: cxxdox and moxygen — the tool choice, decided + +After Doxide's Tree-sitter parser choked on ordinary C++20 (comma-separated declarations, `auto**`, pointer-to-member) and dropped enum values, two robust-parser alternatives were piloted on the same two files. + +**cxxdox** (`mkdocs-cxxdox`, libclang, an MkDocs plugin): configured + loaded correctly, but **couldn't render on macOS** — it ships prebuilt wheels for Linux + Windows only (bundled libclang 21), no mac wheel, and its `cindex.py` is version-locked to libclang 21 (mac has clang 17/22). CI (ubuntu, Linux wheel) would work and be uv-installable, but local mac dev breaks — losing the "same build locally and in CI" property. Also: libclang needs a **real compile context** (sysroot + full `-I` set) to parse a header, heavier than syntax-only tools. Adoption: v0.1.6, essentially KFR-only — the weakest of all. + +**moxygen** (real Doxygen → XML → Markdown, a Node tool) — **the clear winner:** +- **Doxygen parsed our C++20 with ZERO errors** — ate exactly the comma-declarations Doxide choked on. 17 XML files, both the core parser AND GameOfLifeEffect (the catalog file Doxide gave nothing for). +- **Rendered the FULL class** — every member + method + inheritance + source line — and **enum values in a table** (`CurrentState`/`Rpc`/… — the exact thing Doxide dropped). +- **Worked from our plain `//` comments** (`JAVADOC_AUTOBRIEF` + `EXTRACT_ALL`) — **no comment conversion needed** for structure (add `///`/`///<` later to enrich descriptions). +- **Rendered cleanly in our Material site** — Material tables, our theme/nav. +- Doxygen is the **de-facto-standard parser** (25 years, LLVM/Qt/…) — best on the *Common patterns first* axis of any candidate. +- **Cost:** a two-step pipeline (Doxygen binary + the `moxygen` npm tool). Neither is uv-native — Doxygen is a brew/apt binary, moxygen is `npx`. But both are trivially available on CI (`apt install doxygen`, `npx moxygen`), and Doxygen needs *less* per-file compile-context wrangling than libclang. + +### DECISION: moxygen (Doxygen → Markdown) for Phase 4b + +It's the only option that is simultaneously robust (zero parse errors on our real code), complete (enum values + full members, incl. the catalog file), recognizable (Doxygen is THE standard), and MkDocs-native (Markdown output into our one site). The cost is a Doxygen + Node CI dependency — heavier than uv, but standard and reliable, and justified at the introduction site like the ESP-IDF Python exception. + +**Still true regardless of tool:** autodoc is for **services & infrastructure** only. For catalog module types the card remains the doc (controls are runtime `add()` calls no static tool sees). moxygen *can* show a catalog class's raw C++ members — useful to a developer — but that's a *secondary* developer view, not the user-facing card. + +### Phase-4b implementation shape (next commit) +- CI: `apt install doxygen` + `npx moxygen` in the deploy-pages job (and a local `scripts/docs/` wrapper so `build_docs` can run it where doxygen is present; skip gracefully where it isn't, like cxxdox's local-mac gap — but moxygen degrades better since doxygen is brew-installable on mac). +- A `Doxyfile` scoped to a curated `services & infrastructure` file list (core/ + light-base), `GENERATE_XML`, `EXTRACT_ALL`, `JAVADOC_AUTOBRIEF`. +- moxygen → `.md` into a `Developer / Source` MkDocs nav section. +- Enrich the highest-value headers' comments with `///`/`///<` incrementally (as files are touched), but structure works from plain `//` on day one. +- Keep `pymdownx.snippets` for the wire-format/constant embeds already in place. diff --git a/docs/history/plans/Plan-20260702 - Docs Phase 4b (source-generated technical docs, superseded by Docs v2).md b/docs/history/plans/Plan-20260702 - Docs Phase 4b (source-generated technical docs, superseded by Docs v2).md new file mode 100644 index 00000000..760c5eed --- /dev/null +++ b/docs/history/plans/Plan-20260702 - Docs Phase 4b (source-generated technical docs, superseded by Docs v2).md @@ -0,0 +1,66 @@ +# Plan — Phase 4b: source-generated technical docs (the inversion) — SUPERSEDED by Docs v2 + +**Outcome: superseded.** The core idea (generate technical docs from `.h` `///` comments via Doxygen→moxygen) shipped, but this plan's *page model* — a curated `INFRA_HEADERS` list feeding virtual `moonmodules/api/` pages — was replaced by the Docs v2 plan: exhaustive per-header discovery, domain-nested `moonmodules/{core,light}/moxygen/` output, and the two-surface (summary + generated) structure. Kept as the design record of the intermediate step. + +Approved-pending. The final phase of the docs overhaul ([docs/backlog/docs-system-overhaul.md](../../backlog/docs-system-overhaul.md)), on `next-iteration`, after the Doxide→moxygen tool evaluation (see the pilot plan for why moxygen won). + +## The inversion (PO directive) + +**Prerequisite: all *technical* documentation is generated from source code.** Turn the reasoning around — the source `.h` is the single home of technical content; the site's technical pages are *generated views* of it. No hand-written `.md` restates what a `///` comment can carry. + +This extends the model already proven for **catalog** modules (effects/modifiers/layouts/drivers): their cards are generated (from the prose `### ` blocks + the runtime `controls_.add` calls) by `mkdocs_hooks.py`. Now **infrastructure** modules (core services + light-base) get the same treatment via **moxygen** (Doxygen → Markdown), generated at build time by the same hook. + +### The two module kinds, both source-generated (unchanged distinction, now symmetric) + +- **Catalog module types** (effects/modifiers/layouts/drivers): end-user **cards**, generated from the catalog `.md` prose blocks + the `controls_.add` calls. The card is the whole story; a `⌄ details` anchor + links sit in the Links column. **Unchanged** — already source-driven. +- **Services & infrastructure** (core `*Module`, light-base `Layer`/`Buffer`/`MappingLUT`/…): a **generated API page** per module, produced by Doxygen+moxygen from the `///` comments in the `.h`, at build time. This is the new work. + +## Build-time generation (not runtime — MkDocs is static) + +MkDocs → flat HTML on GitHub Pages; no server, so no click-time generation. Same outcome at **build time**: `mkdocs_hooks.py` runs Doxygen+moxygen during the build and injects each infra module's generated API page into the virtual file tree (exactly how it already injects `tests/unit-tests.md`). The card / overview links point to those pre-generated pages. To the reader: click the link → see the generated doc. Identical UX, works on static Pages. + +## Deliverables + +### 1. Generated infra API pages (the core inversion) +- Extend `mkdocs_hooks.py`: an `on_files` step that runs Doxygen (a `Doxyfile` scoped to a curated **infrastructure file list** — start with core/ + light-base, ~24 files, excluding catalog effects/modifiers/layouts/drivers) → moxygen (with the **compact custom template** from the pilot: no path leak via `STRIP_FROM_PATH`, no `--anchors`, member tables not duplicated) → inject the resulting `.md` under `moonmodules/api/<Module>.md` into the virtual tree. Generated fresh each build; never committed. +- Graceful skip where `doxygen`/`npx` are absent (like the installer-staging serve-only guard) so a contributor without doxygen can still build the rest of the site — the API pages just don't generate locally (they do in CI). Log clearly. +- CI: `apt install doxygen` in the deploy-pages job; `npx moxygen` needs Node (already on the runner). The one justified non-uv exception (like ESP-IDF), stated at the introduction site. + +### 2. Retarget `.h` links → generated API pages +- The card's `[.h]` link (currently rewritten to a GitHub blob URL by `_rewrite_out_of_docs_links`) instead points to the module's **generated API page** where one exists (`moonmodules/api/<Module>.html`), falling back to the GitHub blob URL for files with no generated page. So "click .h" → the in-site generated reference, not raw GitHub. +- Same for the per-module overview pages' source links. + +### 3. Prior art cleanup (PO directive) +- **projectMM v1/v2 prior art: removed entirely** (already done across 13 docs — verify none remains, incl. any that crept back). +- **MoonLight prior art: kept but demoted** — it lives only in the **end-user-facing** overview/card `.md` (as an `Origin:`/lineage line), NOT in the technical/generated layer. The generated API pages carry no prior art (Doxygen doesn't emit it; good). + +### 4. Enrich Control.h + FireEffect.h to "perfect documentation" (the pilot's finish) +- Fold the current `Control.md` / FireEffect-card *technical* content into `///`/`///<` comments in the source, so the generated page is complete enough that a developer reads it and thinks "I understand exactly what this does." (Started in the pilot — the `ControlType` value table already generates from `///<`.) +- What CAN'T move to source stays in a thin overview `.md`: cross-file design rationale (Control's Memory/Persistence/Design sections), the 4-column Type×Storage×UI×DMX *matrix* (a format moxygen's flat enum table can't produce — keep as a hand-authored table in the overview), and the MoonLight lineage. +- FireEffect: its description folds into the class `///`; the **card keeps** the GIF + control ranges/user-descriptions (runtime `add()` calls moxygen can't see — catalog stays card-first). + +## Where each kind of content lives (PO: `///` in the `.h` FIRST, `.md` only as secondary) + +The ordering is strict — push everything into the source that can go there: +1. **The module's overview/description → the class `///` comment.** It generates into the API page AND shows on IDE hover. This is the primary home, not a `.md`. +2. **Per-entity technical content** (methods, enums + values, signatures, ranges) → `///`/`///<` in the `.h`. Generated. +3. **A hand-written `.md` ONLY as a last resort**, for what genuinely cannot live in one source file: + - **Cross-file design rationale** (module interactions, buffer-lifecycle coupling) — no single `.h` owns it. + - **Format-specific tables** moxygen can't emit (Control's 4-column Type×Storage×UI×DMX matrix). + - **End-user prose + MoonLight lineage** — the card / a thin overview. + A module whose entire story fits in `///` comments has **no `.md`** — just its generated API page. + +## Files +- **Edit:** `scripts/docs/mkdocs_hooks.py` (Doxygen+moxygen generation + link retargeting), `mkdocs.yml` (nav for the generated API section; moxygen template path), `.github/workflows/release.yml` (`apt install doxygen`), the curated infra `.h` files (progressively `///`-enrich, starting Control.h + a few), the shrunk infra overview `.md` (Control.md → cross-file-only + matrix + lineage), `docs/coding-standards.md` (§ Documentation model: record the inversion + the moxygen mechanism, fill the "autodoc TBD" placeholder), CLAUDE.md pointer. +- **New:** a committed `Doxyfile` (scoped, compact settings) + the moxygen custom template under `scripts/docs/`. + +## Verification +- Build generates an API page per infra module; a card `.h` link lands on it in-site; `Control` page reproduces Control.md's type reference from `///<`. +- Doxygen absent locally → build still succeeds (API pages skipped, logged); present in CI → pages appear. +- No v1/v2 anywhere; MoonLight only in end-user `.md`. +- Enriched `.h` compile clean (`///` inert); ctest green. +- A developer reviewing the generated Control + FireEffect pages judges them complete. + +## Staged rollout (not big-bang) +- **This commit:** the generation machinery + retargeting + Control.h/FireEffect.h enriched + Control.md shrunk, as the proof. A *curated* infra file list (Control, the frame types, a couple of light-base), not all 24. +- **Later, incremental:** `///`-enrich more infra headers as they're touched (the "replaced as files are touched" pattern), growing the generated set; shrink each infra overview `.md` to cross-file-only as its source gets enriched. diff --git a/docs/history/plans/Plan-20260702 - Docs site Phase 0 (MkDocs Material).md b/docs/history/plans/Plan-20260702 - Docs site Phase 0 (MkDocs Material).md new file mode 100644 index 00000000..221e9e07 --- /dev/null +++ b/docs/history/plans/Plan-20260702 - Docs site Phase 0 (MkDocs Material).md @@ -0,0 +1,50 @@ +# Plan — Docs site Phase 0 (MkDocs Material at Pages root) + +Approved 2026-07-02. The full multi-phase design study lives in [docs/backlog/docs-system-overhaul.md](../../backlog/docs-system-overhaul.md); this plan is **only Phase 0** — the additive site-standup. Phases 1–4 (nav split, generated-test folding, fact de-duplication, Doxide source drill-down) each need a separate go-ahead and are not built here. + +## Goal + +End users currently read 259K words of docs as raw `.md` on github.com — no landing page, no nav, no search, because **GitHub Pages today publishes only the web installer** (`docs/install/`), never the docs. Phase 0 gives the existing docs a rendered front door with zero content changes and zero file moves. + +## What ships + +1. **`mkdocs.yml`** at repo root — Material for MkDocs, instant search, `pymdownx.snippets` (for later phases), and a `nav:` tree imposing a top-down end-user → developer order over the *existing* files. No file is moved or rewritten; `nav:` just orders what's there. `history/` and `backlog/` stay out of the published nav (internal). +2. **`docs/index.md`** — the landing page end users lack: what projectMM is → a prominent "Flash an ESP32" call-to-action routing to `/install/` → first-light → effects → developer drill-down. Preserves every affordance of the old `docs/landing/index.html` (Flash button, GitHub/Releases links) but as the docs home, with Docs/Getting-started links now resolving to *rendered* pages instead of github.com blobs. +3. **`scripts/docs/build_docs.py`** — a PEP-723 (`# /// script`) uv wrapper around `mkdocs build`, matching the existing `scripts/docs/` convention and the uv-everywhere rule. (As shipped: CI runs a plain `build`, which fails on missing pages / bad nav; broken links and stale anchors stay `warn`-level per the `validation:` block in `mkdocs.yml`. `--strict` is a *local* anchor-audit option, documented in `scripts/MoonDeck.md`, not the CI gate — the intentional out-of-`docs/` source links would make `--strict` fail the build.) +4. **CI wiring** — the existing `deploy-pages` job (in `.github/workflows/release.yml`, gated to `main`) builds the MkDocs site into `pages/` root **instead of** copying the single `docs/landing/index.html`. `pages/install/` staging is untouched. Add `docs/**` + `mkdocs.yml` to the workflow's `paths:` so a docs-only change to `main` redeploys. + +## Decisions locked (PO) + +- **Scope:** Phase 0 only; later phases separately approved. +- **URL:** docs at Pages root `/`; installer stays at `/install/` (no installer links break; only `mkdocs.yml` is added to the repo — the site is assembled in CI into a throwaway dir, exactly as the installer already is, so no new repo folders). +- **Doxide comment style:** approved for the eventual Phase 4 (recorded for later). + +## Out of scope (deferred to later phases) + +- De-duplicating facts (control names, attribution, ranges) — Phase 3. +- Folding generated test docs into the build / surfacing tests on module pages — Phase 2. +- Doxide source drill-down — Phase 4. +- Any rewrite, move, or deletion of existing `.md` content. + +## Verification + +- `uv run scripts/docs/build_docs.py --strict` builds locally with no warnings; every nav entry resolves; search index generates. +- Old `docs/landing/index.html` retired (its Flash button + links live on in `docs/index.md`); `check_specs.py` + the doc-generation `--check` still green (Phase 0 touches no specs/tests). +- CI `deploy-pages` publishes the rendered docs at `/`, installer still reachable at `/install/`. + +## Files + +- **New:** `mkdocs.yml`, `docs/index.md`, `scripts/docs/build_docs.py`. +- **Edit:** `.github/workflows/release.yml` (build MkDocs into `pages/` root; add docs paths to `paths:`). +- **Delete:** `docs/landing/index.html` (folded into `docs/index.md`) — and drop its `docs/landing/**` from the workflow `paths:`. + +## Landed alongside: `docs/install/` → top-level `web-installer/` + +During implementation the PO asked to fix the deeper structural issue the site standup surfaced: `docs/` held three unlike things — doc-site source, a standalone web app (`install/`), and transient internal notes (`history/` + `backlog/`). Decision (PO, "go for the best, not to keep technical debt" — a young project optimizes for the end state): + +- **Moved the installer app out of `docs/`** to a top-level `web-installer/` (`git mv`, history preserved). It's an application, not documentation — the miscategorization worth fixing permanently. The **deployed URL stays `/install/`**: the release workflow maps `web-installer/` → `pages/install/`, so QR codes, deployed-device OTA URLs, and existing links keep working (zero external breakage). +- **Left `history/` + `backlog/` in `docs/`**, excluded from the site (`exclude_docs`). They're transient — slated for compaction — so relocating them would be discarded churn; keeping them out of the *published* site is all the reader-facing goal needs. + +Swept ~104 references (`docs/install/` → `web-installer/`) across scripts, both CI workflows, three check scripts, MoonDeck, JS + Python test suites, CLAUDE.md commit-gate triggers, and published docs. **Gotcha caught:** a literal-path regex misses split-component construction — `join(ROOT, "docs", "install", …)` (3 JS tests) and `"docs" / "install"` (moondeck.py) needed separate patterns; verified by running the JS/Python suites (all green). + +Deferred to a **separate next commit** (PO): top-level `scripts/` → `moondeck/` — larger orthogonal sweep, spec'd in `docs/backlog/rename-scripts-to-moondeck.md`. diff --git a/docs/history/plans/Plan-20260702 - Docs v2 (two-surface module docs).md b/docs/history/plans/Plan-20260702 - Docs v2 (two-surface module docs).md new file mode 100644 index 00000000..273a2f45 --- /dev/null +++ b/docs/history/plans/Plan-20260702 - Docs v2 (two-surface module docs).md @@ -0,0 +1,85 @@ +# Plan — Docs v2: two-surface module documentation + +Approved-pending. Executes the redefined [§ Documentation model](../../coding-standards.md#documentation-model) (agreed first, per CLAUDE.md). On `next-iteration`, continuing the docs overhaul. + +## Goal + +Collapse every module's documentation to **two surfaces** and delete the rest: +1. a hand-written **summary page** per module *group* (end-user, a 4-column table + cross-file prose), and +2. a **generated technical page** per module (`{core,light}/moxygen/<Module>.md`, 100% from the `.h`). + +End state: the ~30 per-module standalone `.md` files are gone; each module's story lives in its `.h` (`///` → generated page) and its group summary row. This supersedes the earlier Phase-4b "infra API pages" model (virtual `moonmodules/api/`, `INFRA_HEADERS`), which becomes the general mechanism for *all* modules, retargeted to the domain-nested `moxygen/` dirs. + +## Why this ticks the boxes (sanity check vs CLAUDE.md) + +- **No duplication / Document once:** a fact lives in the `.h` **or** the summary row, never both, never a third per-module `.md`. +- **Default to subtraction:** the change *deletes* ~30 `.md` files; net-negative doc count. +- **Common patterns first:** the guide-over-generated-reference split is the docs.rs / Sphinx-autodoc / Doxygen pattern (named in the standard). +- **Complexity in core:** the generation machinery (`gen_api.py` + hook) is the one complex piece; every module then gets a page for free. + +## Inventory (what moves where) + +**Absorb into `.h` `///` + a summary row, then delete (~29 files):** +- **Core (12 + `ui.md`):** AudioModule, Control, DevicesModule, FilesystemModule, FirmwareUpdateModule, HttpServerModule, I2cScanModule, ImprovProvisioningModule, MoonModule, NetworkModule, Scheduler, SystemModule — each maps 1:1 to a `src/core/*.h`. `ui.md` has no `.h` (it documents the UI *system*, not a module) → its content folds into the core-UI summary page's prose, not a `///`. +- **Light supporting (10):** Buffer, MappingLUT, Layer, ModifierBase, Layouts, Layers, BlendMap, Drivers, EffectBase, LightConfig — each maps to a `src/light/**/*.h`. +- **Catalog detail (7):** NetworkSendDriver, RmtLedDriver, LcdLedDriver, HueDriver, ParlioLedDriver, PreviewDriver, MoonLiveEffect — already card-backed; their cross-file prose (where any) moves to a summary-page section, their `///` gets enriched, the detail `.md` is deleted. + +**Keep (the 4 catalog summary pages, restructured to link moxygen):** `light/{effects,modifiers,layouts,drivers}/<type>.md`. + +**Create (3 new summary pages, nested per the standard):** +- `core/ui/` — core UI modules (FileSystem, System+Audio+I2C, FirmwareUpdate, Network+Improv+Devices). +- `core/supporting/` — core supporting (Control, Scheduler, MoonModule, HttpServer). +- `light/supporting/` — light supporting (the 10 above). + +## Staged execution (curated proof first, then sweep) + +**Stage 1 — machinery (retarget the generator, one change):** +- `gen_api.py`: replace `INFRA_HEADERS` (flat list → `moonmodules/api/`) with a per-domain resolver that emits `moonmodules/core/moxygen/<Module>.md` and `moonmodules/light/moxygen/<Module>.md`. The header list broadens to *all* documented modules (core + light), discovered from `src/{core,light}` rather than hand-listed, so it can't drift as modules are added. +- `mkdocs_hooks.py`: `_API_MODULES` + `_rewrite_out_of_docs_links` retarget `.h` links to the new `{domain}/moxygen/<Module>.md` path (currently hardcodes `moonmodules/api/`). The domain is derivable from the module's src path. +- `.gitignore`: `docs/moonmodules/*/moxygen/` (gitignored, per the standard). +- Verify: build generates pages under both `core/moxygen/` and `light/moxygen/`; a summary link lands on one. + +Re-staged (PO): ship a **working system first** (all generated pages + summary pages, old `.md` kept), commit that as a complete baseline, *then* optimize the generated pages, and delete the old `.md` only at the very end. De-risks: a committable, coherent system exists before any enrichment or deletion. + +**Stage 2 — machinery + template shape (done):** +- **Moxygen template tuning.** Private members leaked (`Private Attributes`/`Private Methods`) because Doxygen's XML backend emits documented privates regardless of `EXTRACT_PRIVATE`. Fixed with a Handlebars denylist in `class.md` on the raw section `kind` (moxygen's own lever, no post-processing) — public-only reference. +- **`.md` on disk, standard flow.** `gen_api.generate()` writes each page to `docs/moonmodules/{domain}/moxygen/<Module>.md` (gitignored) so a human previews the `.md` directly and MkDocs discovers it as a normal source file. +- **Temporary cross-check header.** Each generated page opens with a `> _Migration cross-check (temporary):_` line linking the source `.h` and (while it still exists) the original `.md`, so a reviewer can confirm the `.md`'s content was absorbed. Removed at Stage 4. +- **Proof on all three flavours:** Control (core supporting), Buffer (light supporting), PreviewDriver (catalog) `///`-enriched incl. per-method descriptions. + +**Stage 3 — "working system" (→ commit):** +- Generate all 132 pages (cross-check header on each); build the 3 summary pages (core-UI, core-supporting [done], light-supporting), same `### `-block → 4-col-table transform every summary page uses. +- **Keep every old `.md`.** No deletions, no retargeting of old pages' links. The new system lands *alongside* the old — a complete, committable baseline. **Commit here.** + +**Stage 4 — optimize (incremental, per module):** +- Sweep the `///` comments module-by-module so each generated page reads as excellent developer docs (consolidate existing `//` into `///`, add per-method/attribute descriptions, fold the old `.md`'s cross-file rationale into the class `///` or the summary page). Cross-check against the still-present old `.md` via the temporary header. +- Apply the two rendering gotchas from the standard on every `///` touched (backtick `<…>`, no mid-sentence `.`). +- **Control-name drift guard.** A summary page's `- name — …` param lines are hand-authored (the controls come from runtime `controls_.add("name", …)` calls no static tool sees — same reason catalog cards hand-author them). They can drift when a control is renamed in the `.h`. Extend `check_specs.py` (which already validates numeric *ranges* between `.h` and catalog docs) to also validate that each summary-page param *name* matches a real `controls_.add("<name>", …)` in the module's source — so a renamed/removed control fails the check. Applies to every summary page with a Parameters column (Core UI + the few supporting modules with controls). + +**Stage 5 — switchover + reconciliation (last, atomic):** +- Once the generated pages are good: **atomically** delete all ~29 old per-module `.md`, retarget every inbound link (links from other deleted `.md` vanish with them; links from permanent docs — architecture.md, surviving summary pages — repoint at the summary page or the generated page), and remove the temporary cross-check header from `gen_api.py`. +- `mkdocs.yml` nav (34 per-module entries): replace the per-module page list with the summary pages + a generated-pages note (moxygen pages are link-reachable, not nav-listed — same policy as history/backlog). +- `check_specs.py`: exemption path already updated to `{domain}/moxygen/` + discovery list (Stage 2). +- **architecture.md line ~112** ("Each MoonModule is documented in `docs/moonmodules/` as it is built") → rewrite to the two-surface model, so the two docs agree (PO-approved to land in this change). +- Clean the stale Phase-4b `api/` naming in `gen_api.py`'s docstring. + +## Files + +- **Edit:** `scripts/docs/gen_api.py` (domain-nested output + module discovery), `scripts/docs/mkdocs_hooks.py` (link retarget), `scripts/check/check_specs.py` (exemption path), `mkdocs.yml` (nav), `.gitignore` (moxygen dirs), `docs/architecture.md` (line ~112), the ~29 `src/**/*.h` (enrich `///`), the 4 catalog summary pages (link retarget). +- **New:** 3 summary pages (`core/ui/`, `core/supporting/`, `light/supporting/`). +- **Delete:** ~29 per-module `.md` (12 core + `ui.md` + 10 light-supporting + 7 catalog-detail — as each is absorbed), `docs/poc/`. + +## Out of scope (named, not silently dropped) + +- **Committing the moxygen output** — stays gitignored; the commit+drift-gate is a later one-line flip if PR-review of generated docs earns it. +- **Test-doc generation via moxygen** — the standard already rules it out (unit tests are macros, scenarios are JSON); `generate_test_docs.py` is untouched. +- **Splitting catalog pages** (`effects_wled.md` / `effects_moonmodules.md`) — future, when a page gets too long. + +## Verification + +- `docs/moonmodules/` contains only summary pages (no per-module `.md`); every module has a `{domain}/moxygen/<Module>.md` at build. +- A summary link lands on the generated page in-site; no broken nav/links (mkdocs build clean). +- Enriched `.h` compile clean (`///` inert); `ctest` + scenarios green; `check_specs.py` green. +- architecture.md and coding-standards.md agree on the doc model (no contradiction). +- Net doc-file count **down** (~29 deleted, 3 created). +- PO reads a generated page (Control) + a summary page and judges them complete. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..4e6725d3 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,48 @@ +# projectMM + +High-performance LED & DMX lighting control for ESP32 and beyond. + +[:material-flash: Flash an ESP32 from your browser](/projectMM/install/){ .md-button .md-button--primary } +  +[:material-github: GitHub](https://github.com/MoonModules/projectMM){ .md-button } + +!!! tip "New here?" + The [Getting started](gettingstarted.md) guide walks you from a blank ESP32 to your first running light show, step by step — no build tools required. + +## What it is + +projectMM drives large LED installations and DMX fixtures. You build a light show by stacking simple blocks — a **layout** (how the LEDs are arranged), one or more **effects** (what they animate), **modifiers** (mirror, rotate, mask…), and a **driver** (how the pixels reach the hardware). Every setting takes effect live; there is no reboot to apply a change. + +It runs on ESP32 (the primary target), and also on Teensy, macOS, Windows, Linux, and Raspberry Pi. + +## Find your way + +<div class="grid cards" markdown> + +- :material-download: **Get running** + + Flash a board from your browser and light your first pixels. + + [Getting started](gettingstarted.md) · [Web installer](/projectMM/install/) + +- :material-palette: **Build a show** + + Browse the effects, layouts, modifiers, and drivers you compose into a show. + + [Effects](moonmodules/light/effects/effects.md) · [Layouts](moonmodules/light/layouts/layouts.md) · [Modifiers](moonmodules/light/modifiers/modifiers.md) + +- :material-check-decagram: **See what's verified** + + Every behaviour is pinned by a test. When a bug is fixed, a test proves it. + + [Unit tests](tests/unit-tests.md) · [Scenario tests](tests/scenario-tests.md) + +- :material-code-braces: **Go deeper** + + System design, the module model, and the per-module reference. + + [Architecture](architecture.md) · [Core modules](moonmodules/core/supporting/supporting.md) · [Light pipeline](moonmodules/light/supporting/supporting.md) + +</div> + +The web installer works in Chrome & Edge (Web Serial) — no download required. diff --git a/docs/landing/index.html b/docs/landing/index.html deleted file mode 100644 index 2e0060ee..00000000 --- a/docs/landing/index.html +++ /dev/null @@ -1,76 +0,0 @@ -<!doctype html> -<html lang="en"> -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>projectMM - - - - - -
-

projectMM

-

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

- - ⚡ Flash an ESP32 from your browser - -

New here? Read the step-by-step getting-started guide →

- - - -

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

-
- - diff --git a/docs/moonmodules/core/AudioModule.md b/docs/moonmodules/core/archive/AudioModule.md similarity index 83% rename from docs/moonmodules/core/AudioModule.md rename to docs/moonmodules/core/archive/AudioModule.md index 88ad5ffb..144b773f 100644 --- a/docs/moonmodules/core/AudioModule.md +++ b/docs/moonmodules/core/archive/AudioModule.md @@ -1,6 +1,6 @@ # AudioModule -Acquires an audio source and publishes an **AudioFrame** — an overall sound **level**, a 16-band frequency **spectrum**, and the **dominant peak**. The frame is available to consumers every render tick, but its analysed values are *recomputed* only when a full sample block has accumulated (a 512-sample block at 22 kHz takes ~23 ms, longer than one tick), so a tick that doesn't complete a block re-publishes the previous `AudioFrame` unchanged rather than re-analysing. It is the producer half of the audio-reactive pipeline; [AudioVolumeEffect](../light/effects/effects.md) and [AudioSpectrumEffect](../light/effects/effects.md) are the consumers. +Acquires an audio source and publishes an **AudioFrame** — an overall sound **level**, a 16-band frequency **spectrum**, and the **dominant peak**. The frame is available to consumers every render tick, but its analysed values are *recomputed* only when a full sample block has accumulated (a 512-sample block at 22 kHz takes ~23 ms, longer than one tick), so a tick that doesn't complete a block re-publishes the previous `AudioFrame` unchanged rather than re-analysing. It is the producer half of the audio-reactive pipeline; [AudioVolumeEffect](../../light/effects/effects.md) and [AudioSpectrumEffect](../../light/effects/effects.md) are the consumers. It is named for what it does, audio acquisition plus analysis, not for one source: today the source is a digital I²S MEMS microphone (the only one wired), and the same analysis pipeline is built to serve other sources (line-in, USB audio) behind the platform read seam as they are added. The candidate source types — I²S with an MCLK for line-in, PDM mics, analog line-in, and I²C-configured codecs — are surveyed in the [Troy (troyhacks)](#prior-art) prior-art notes below. Most of the module is the analysis (DC-blocker, RMS level, windowed FFT, band mapping), which is source-independent. @@ -34,7 +34,7 @@ The DSP choices are the textbook defaults on purpose: a **Hann** window, **RMS** ## 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. +- `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. @@ -50,13 +50,13 @@ The first ~250 ms after the I²S clock starts are power-on settling garbage; the ## 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). +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. +**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 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. @@ -67,13 +67,13 @@ Audio-reactive lighting is a long-standing idea in the LED-controller world (WLE - **I²S with an MCLK, for line-in.** The [INMP441 is self-clocked and has no MCLK](#hardware-inmp441-class-digital-mic); a line-in codec generally needs a master clock the ESP32 drives. Adding an optional `mclkPin` to the I²S read seam covers the line-in case without changing the analysis. - **PDM mics**, for boards that ship with one. A different I²S sub-mode (the IDF [`i2s_pdm`](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/i2s.html) driver) — another variant behind the same platform read, not a new pipeline. - **Analog line-in.** Long held to "only the original ESP32," but the field has moved: **[DedeHai](https://github.com/DedeHai) got analog input working on the S3**, and **Troy got it working in his ParrotRadio project**. Troy flags a *testing-confidence* nuance worth recording: he considers his own ParrotRadio analog path **better exercised** — he was actually recording and playing audio back through it and chasing down real issues — whereas an unlistened-to analog path elsewhere may not be as accurate as it looks, "if nobody's ever listened to it." So if projectMM adopts analog line-in, **validate by listening**, not just by watching the level meter move. -- **I²C-configured codecs** (e.g. the **ES8311**): the right move is explicitly **not** to hand-roll each codec's register config (which is what Troy did in WLED). Espressif ships an **[`esp_codec_dev`](https://components.espressif.com/components/espressif/esp_codec_dev) "codecs" component** for IDF that already carries the option tables for many codecs; pulling it in would support "a bunch more codecs for free" and let users configure them for their own hardware. If something Troy hand-rolled turns out to be missing from the component, the codec class is extensible — but he doubts anything is. This is the [*Industry standards, our own code*](../../../CLAUDE.md#principles) call applied to codec bring-up: take Espressif's component rather than a bespoke per-codec config. +- **I²C-configured codecs** (e.g. the **ES8311**): the right move is explicitly **not** to hand-roll each codec's register config (which is what Troy did in WLED). Espressif ships an **[`esp_codec_dev`](https://components.espressif.com/components/espressif/esp_codec_dev) "codecs" component** for IDF that already carries the option tables for many codecs; pulling it in would support "a bunch more codecs for free" and let users configure them for their own hardware. If something Troy hand-rolled turns out to be missing from the component, the codec class is extensible — but he doubts anything is. This is the [*Industry standards, our own code*](../../../../CLAUDE.md#principles) call applied to codec bring-up: take Espressif's component rather than a bespoke per-codec config. -Troy also has **DSP boards on his desk** — essentially I²S front-ends "waaaaay beyond the regular codecs" — a class of source recorded here so the line-in / codec work leaves room for it rather than only the simple cases. All of the above is **source-seam** work: it widens what feeds the pipeline, leaving the DC-blocker / RMS / FFT / band analysis untouched. Tracked under [backlog](../../backlog/README.md). +Troy also has **DSP boards on his desk** — essentially I²S front-ends "waaaaay beyond the regular codecs" — a class of source recorded here so the line-in / codec work leaves room for it rather than only the simple cases. All of the above is **source-seam** work: it widens what feeds the pipeline, leaving the DC-blocker / RMS / FFT / band analysis untouched. Tracked under [backlog](../../../backlog/README.md). ## Adaptive noise gate: forward-looking -> **Present-tense exception (justified).** Module specs are otherwise present-tense ([CLAUDE.md](../../../CLAUDE.md)); this section is forward-looking by deliberate choice, so the design analysis stays with the module it extends. It describes a concept and our judgement of it, not shipped behaviour. The shipped audio path is everything above. +> **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. @@ -90,13 +90,13 @@ Five design constraints come with it, and they are the load-bearing part: (1) sa ### 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. +**Yes, directionally, and it is squarely industry-standard.** A hysteresis noise gate with a fast-attack/slow-release envelope is the textbook design for exactly this problem (it is how studio gates, two-way-radio squelch, and voice-activity detectors all work), so adopting it moves us *toward* the recognisable solution and *away* from the borrowed `squelch` constant, which is the right direction under the [*Industry standards, our own code*](../../../../CLAUDE.md#principles) principle. The relative-threshold insight (constraint 3) is the genuinely valuable core: a gate keyed to a *learned* floor self-calibrates to whatever mic or line source is connected, where an absolute squelch only ever suits one setup. So the idea is sound and worth doing. **Two cautions keep it from being a drop-in.** First, **timing is tight and must be proven, not assumed.** A 512-sample block at 22050 Hz is already ~23 ms of buffering before analysis begins; that leaves under ~7 ms of the 30 ms budget for everything the gate adds. The block size, not the gate, is the dominant cost, so any smoothing the gate introduces must be cheap (one-pole) and the *open* path especially must not lengthen it. This is measurable on hardware and a hard gate on the design. Second, it overlaps work we have already scoped (the per-band floor, below), so the risk is building a parallel mechanism instead of one coherent one. Both push the same way: **decompose and adopt in steps, do not overhaul.** ### Does our per-band floor already cover part of this? -Partly, and that overlap is the key to sequencing. The backlogged [per-band noise-floor](../../backlog/README.md) learns each band's idle baseline and subtracts it, so a *steady single-frequency* tone (our bench's ~258 Hz mains hum) gates to dark while the other bands stay live. The proposed time-domain gate answers a *different* question, "is there any sound at all," across the whole signal. They are complementary halves, not competitors: the per-band floor is the **frequency-domain** noise floor, the gate is the **time-domain** one. The per-band floor is also the smaller, already-planned step, so it is the natural first increment, and it is genuinely "part of this idea," not a thing the gate replaces. +Partly, and that overlap is the key to sequencing. The backlogged [per-band noise-floor](../../../backlog/README.md) learns each band's idle baseline and subtracts it, so a *steady single-frequency* tone (our bench's ~258 Hz mains hum) gates to dark while the other bands stay live. The proposed time-domain gate answers a *different* question, "is there any sound at all," across the whole signal. They are complementary halves, not competitors: the per-band floor is the **frequency-domain** noise floor, the gate is the **time-domain** one. The per-band floor is also the smaller, already-planned step, so it is the natural first increment, and it is genuinely "part of this idea," not a thing the gate replaces. ### How to decompose it: cherry-pick, step by step @@ -109,11 +109,11 @@ The whole proposal is more than one increment. Taken apart, most of its value la Each step is its own commit, host-tested red-first, and leaves the system working; none requires touching `AudioBands.h` or the effect consumers. Steps 1–2 deliver most of the benefit (a self-calibrating floor in both domains) with almost no timing cost; 3–4 are polish to layer on only if the bench says they earn their place. -**What it eventually retires:** the `floor` knob's role as a hard squelch. `floor` would become the *display* noise-floor only (the dB-window bottom in `magToByte`), while the learned gate decides "is there sound." That is a clean subtraction, but it is the *end* of the path, not the first step. Tracked under [backlog](../../backlog/README.md). +**What it eventually retires:** the `floor` knob's role as a hard squelch. `floor` would become the *display* noise-floor only (the dB-window bottom in `magToByte`), while the learned gate decides "is there sound." That is a clean subtraction, but it is the *end* of the path, not the first step. Tracked under [backlog](../../../backlog/README.md). ## Tests -Full case lists are in the generated inventories — [unit tests § AudioModule](../../tests/unit-tests.md#audiomodule) and [scenario tests § AudioModule](../../tests/scenario-tests.md#audiomodule) (both regenerated from the test files, so they never drift). What each layer covers: +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). @@ -122,4 +122,4 @@ Full case lists are in the generated inventories — [unit tests § AudioModule] ## 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) +[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/Control.md b/docs/moonmodules/core/archive/Control.md similarity index 79% rename from docs/moonmodules/core/Control.md rename to docs/moonmodules/core/archive/Control.md index 8b18cfd1..7f49b31f 100644 --- a/docs/moonmodules/core/Control.md +++ b/docs/moonmodules/core/archive/Control.md @@ -30,7 +30,7 @@ Notes on the non-obvious ones (the rest are self-describing): - **Password** serializes XOR-obfuscated + base64 over `/api/state`, not plaintext — a first line of defence, trivially reversible by design (the XOR key is shared with `app.js`), not encryption. - **Int16** is for coordinate-style values where negatives are legal. Default bounds are the full int16 range; pass explicit bounds for a tighter one. The UI renders it as a slider (an unbounded int16 falls back to a ±percentage slider). - **Pin** is a GPIO number — `int8_t` (one byte; a GPIO never exceeds ~54), `−1` = unused/default. Distinct from Int16 so the UI renders a plain **number input** (a GPIO has no meaningful range to drag) and to keep the byte. `min`/`max` are the valid-GPIO span, used only as a server-side write-clamp. [NetworkModule](NetworkModule.md)'s eth pin controls are the first users; LED-driver pins follow. -- **ReadOnlyInt** stores 1 byte + a unit suffix instead of a ~10-byte string — see [coding-standards § Prefer integers](../../coding-standards.md#prefer-integers-store-values-in-their-native-shape). [NetworkModule](NetworkModule.md)'s `rssi` (`-58 dBm`) and `txPower` (`19 dBm`) are the first users. +- **ReadOnlyInt** stores 1 byte + a unit suffix instead of a ~10-byte string — see [coding-standards § Prefer integers](../../../coding-standards.md#prefer-integers-store-values-in-their-native-shape). [NetworkModule](NetworkModule.md)'s `rssi` (`-58 dBm`) and `txPower` (`19 dBm`) are the first users. - **IPv4** stores 4 bytes but converts to/from the dotted-quad string at the JSON boundary (`parseDottedQuad`/`formatDottedQuad` in `Control.h`, used by API, persistence, and scenario set-control). Used for [NetworkModule](NetworkModule.md)'s static-IP fields. No RGB color-picker type — effects use a palette index (uint8_t) instead. `float` and `Coord3D` exist but are used minimally; prefer uint8_t. @@ -45,7 +45,7 @@ Control values persist via [FilesystemModule](FilesystemModule.md), which overla ## Tests -[Unit tests: MoonModule](../../tests/unit-tests.md#moonmodule) — control binding by reference, pointer read/write, clear and rebuild. +[Unit tests: MoonModule](../../../tests/unit-tests.md#moonmodule) — control binding by reference, pointer read/write, clear and rebuild. ## Prior art @@ -53,14 +53,6 @@ Control values persist via [FilesystemModule](FilesystemModule.md), which overla Binds via `reinterpret_cast(&variable)`; UI types "slider"/"select"/"toggle"/"text"/"display". -### projectMM v1 — addControl ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/core/StatefulModule.h)) - -Same pattern; also "display"/"progress"/"button". - -### projectMM v2 — ControlDescriptor ([source](https://github.com/ewowi/projectMM-v2/blob/main/src/core/MoonModule.h#L40)) - -Richer but heavier (default, options array, ownsOptions, system flags) — not all that weight is justified here. Persisted values applied via an `applyPending_` overlay during `onBuildControls()`; projectMM keeps the same timing. - ## Source -[Control.cpp](../../../src/core/Control.cpp) · [Control.h](../../../src/core/Control.h) +[Control.cpp](../../../../src/core/Control.cpp) · [Control.h](../../../../src/core/Control.h) diff --git a/docs/moonmodules/core/DevicesModule.md b/docs/moonmodules/core/archive/DevicesModule.md similarity index 80% rename from docs/moonmodules/core/DevicesModule.md rename to docs/moonmodules/core/archive/DevicesModule.md index 44db02a1..cca92a41 100644 --- a/docs/moonmodules/core/DevicesModule.md +++ b/docs/moonmodules/core/archive/DevicesModule.md @@ -1,6 +1,6 @@ # DevicesModule -![DevicesModule controls](../../assets/core/Devices%20module.png) +![DevicesModule controls](../../../assets/core/Devices%20module.png) A **core**, domain-neutral module that discovers other devices on the LAN, identifies what each is, and presents them as a browsable list. It focuses on *all* devices on the network (including this one, marked as self), not on the host's own state — so its card looks the same on every projectMM instance, ESP32 or PC. Light-domain modules consume the device list; the discovery machinery itself stays domain-neutral. @@ -16,14 +16,14 @@ Discovery state ("idle", "N devices", "N devices (cached)") is reported through Discovery is **passive UDP**: each device **broadcasts** a small presence packet on a well-known port, and this module **listens** (a bound `UdpSocket`, drained non-blocking each tick). No subnet sweep, no per-host probe, **no mDNS query** — a device appears when its broadcast arrives and ages out when it stops. -- **Broadcast.** Every ~10 s (`kBroadcastEverySec`) this module broadcasts a **44-byte WLED-compatible presence packet** (see [`WledPacket`](../../../src/core/WledPacket.h)) to `255.255.255.255:65506`: `token=255, id=1`, our IP, deviceName, board-type byte — plus a projectMM marker stamped into the version field (a region no WLED validator reads). So a peer projectMM device recognises us, **and** a real WLED / WLED app browsing 65506 lists us too. Discovery-only: a WLED that receives it shows us in its instances list, it does **not** sync to it (sync/control is a separate WLED protocol on a port WLED never shares). -- **Listen.** Each `loop1s` tick drains the bound listener with non-blocking `recvFrom` (bounded per tick) and classifies each datagram through the plugins. Never blocks the tick — the hot-path-safe replacement for the former mDNS query, which destabilised our own advertise (a PTR query for a service we also host exhausts the IDF mDNS pool — see the [discovery-transport lesson](../../history/decisions.md)). +- **Broadcast.** Every ~10 s (`kBroadcastEverySec`) this module broadcasts a **44-byte WLED-compatible presence packet** (see [`WledPacket`](../../../../src/core/WledPacket.h)) to `255.255.255.255:65506`: `token=255, id=1`, our IP, deviceName, board-type byte — plus a projectMM marker stamped into the version field (a region no WLED validator reads). So a peer projectMM device recognises us, **and** a real WLED / WLED app browsing 65506 lists us too. Discovery-only: a WLED that receives it shows us in its instances list, it does **not** sync to it (sync/control is a separate WLED protocol on a port WLED never shares). +- **Listen.** Each `loop1s` tick drains the bound listener with non-blocking `recvFrom` (bounded per tick) and classifies each datagram through the plugins. Never blocks the tick — the hot-path-safe replacement for the former mDNS query, which destabilised our own advertise (a PTR query for a service we also host exhausts the IDF mDNS pool — see the [discovery-transport lesson](../../../history/decisions.md)). **mDNS is advertise-only.** `mdnsInit` still announces `_http._tcp`+`mm=1` and `_wled._tcp`+`mac=` so the **native WLED app + Home Assistant discover us** (they only browse mDNS — UDP can't replace that). But this module never *queries* mDNS; all discovery is UDP. ### Plugins (the interop seam) -Foreign ecosystems hook in as **plugins**, not hardcoded branches — the adapter pattern (cf. `ListSource`, `ModuleFactory`). A [`DevicePlugin`](../../../src/core/DevicePlugin.h) declares the UDP port it listens on (`discoveryPort()`) and turns a received datagram into a `Device` kind (`classifyPacket`): +Foreign ecosystems hook in as **plugins**, not hardcoded branches — the adapter pattern (cf. `ListSource`, `ModuleFactory`). A [`DevicePlugin`](../../../../src/core/DevicePlugin.h) declares the UDP port it listens on (`discoveryPort()`) and turns a received datagram into a `Device` kind (`classifyPacket`): | Plugin | Claims (on UDP 65506) | Classifies as | |---|---|---| @@ -36,7 +36,7 @@ The plugin classification is pure and host-unit-tested (`unit_DeviceIdentify.cpp ### Out-of-band devices (Hue bridge) -A device not discovered by UDP presence — a Philips Hue bridge, found over HTTP by a [HueDriver](../light/drivers/HueDriver.md) — registers itself through `upsertHueBridge(ip, name, colourCount)`, reached via `active()` (the boot-instance static accessor, the `AudioModule::latestFrame()` seam shape). The bridge then lists like any device, with a `colour` field (its colour-light count, for sizing a layout). This keeps the module domain-neutral: the Hue HTTP/pairing lives entirely in the light-domain driver; the core only stores the resulting row. (`unit_DevicesModule_hue.cpp` pins the row + its persistence round trip.) +A device not discovered by UDP presence — a Philips Hue bridge, found over HTTP by a [HueDriver](../../light/drivers/HueDriver.md) — registers itself through `upsertHueBridge(ip, name, colourCount)`, reached via `active()` (the boot-instance static accessor, the `AudioModule::latestFrame()` seam shape). The bridge then lists like any device, with a `colour` field (its colour-light count, for sizing a layout). This keeps the module domain-neutral: the Hue HTTP/pairing lives entirely in the light-domain driver; the core only stores the resulting row. (`unit_DevicesModule_hue.cpp` pins the row + its persistence round trip.) ### Age-out @@ -48,11 +48,11 @@ Because the presence broadcast and the mDNS advertise are WLED-shaped, a project **In WLED's own "Sync interfaces" instances list** — a real WLED lists every projectMM board it heard on UDP 65506. (The `undefined` columns are WLED-sync fields projectMM doesn't fill — the presence packet carries identity, not the full WLED sync state; listing is what we're after.) -![projectMM devices in WLED's instances list](../../assets/core/Wled%20discovers%20projectMM.png) +![projectMM devices in WLED's instances list](../../../assets/core/Wled%20discovers%20projectMM.png) **In the native WLED app** (iOS / Android) — discovered via the mDNS `_wled._tcp` advertise, validated via the `/json/info` shim, with live colour + a working brightness slider over the `/ws` WebSocket. See [HttpServerModule § WLED-compatibility shim](HttpServerModule.md#wled-compatibility-shim) for the wire contract (reverse-engineered from the [WLED-Android](https://github.com/Moustachauve/WLED-Android) client). -![projectMM devices in the native WLED app](../../assets/core/WLED%20Native%20discovers%20projectMM.jpeg) +![projectMM devices in the native WLED app](../../../assets/core/WLED%20Native%20discovers%20projectMM.jpeg) ## Transport boundary (discovery vs commands) @@ -74,9 +74,9 @@ The `devices` List serializes (via [Control](Control.md)'s `ControlType::List`) - **mDNS-SD / DNS-SD (Bonjour, Avahi)** — the industry-standard service-discovery pattern this module uses: announce a service, browse for it. WLED, ESPHome, Home Assistant, Hue all speak it. - **WLED** — the `_wled._tcp` service it advertises (and that the native WLED iOS/Android/Desktop apps browse) is the interop target the `WledPlugin` + the `_wled._tcp` advertise serve. -- **MoonLight** ([`ModuleDevices.h`](https://github.com/ewowi/MoonLight/blob/main/src/MoonBase/Modules/ModuleDevices.h)) uses a UDP presence broadcast for device discovery; DevicesModule carries that idea forward — the 44-byte WLED-compatible packet on UDP 65506 (see [`WledPacket`](../../../src/core/WledPacket.h)), written fresh against our architecture. mDNS stays advertise-only, for the foreign apps that discover *us* over it (the WLED native app, Home Assistant). -- The web installer's `docs/install/devices.js` "Your devices" list is the prior art for the device record shape (name / url / type). +- **MoonLight** ([`ModuleDevices.h`](https://github.com/ewowi/MoonLight/blob/main/src/MoonBase/Modules/ModuleDevices.h)) uses a UDP presence broadcast for device discovery; DevicesModule carries that idea forward — the 44-byte WLED-compatible packet on UDP 65506 (see [`WledPacket`](../../../../src/core/WledPacket.h)), written fresh against our architecture. mDNS stays advertise-only, for the foreign apps that discover *us* over it (the WLED native app, Home Assistant). +- The web installer's `web-installer/devices.js` "Your devices" list is the prior art for the device record shape (name / url / type). ## Source -[DevicesModule.h](../../../src/core/DevicesModule.h) · [DevicePlugin.h](../../../src/core/DevicePlugin.h) +[DevicesModule.h](../../../../src/core/DevicesModule.h) · [DevicePlugin.h](../../../../src/core/DevicePlugin.h) diff --git a/docs/moonmodules/core/FilesystemModule.md b/docs/moonmodules/core/archive/FilesystemModule.md similarity index 96% rename from docs/moonmodules/core/FilesystemModule.md rename to docs/moonmodules/core/archive/FilesystemModule.md index 46015b6b..3e740b86 100644 --- a/docs/moonmodules/core/FilesystemModule.md +++ b/docs/moonmodules/core/archive/FilesystemModule.md @@ -1,6 +1,6 @@ # FilesystemModule -![FilesystemModule controls](../../assets/core/FilesystemModule.png) +![FilesystemModule controls](../../../assets/core/FilesystemModule.png) Persists control values to flash so settings survive a reboot. Always loaded, runs first in the scheduler so its load hook fires before any other module's `setup()`. @@ -85,4 +85,4 @@ No files exist → load is a no-op. Modules run with their default member-initia ## Source -[FilesystemModule.cpp](../../../src/core/FilesystemModule.cpp) · [FilesystemModule.h](../../../src/core/FilesystemModule.h) +[FilesystemModule.cpp](../../../../src/core/FilesystemModule.cpp) · [FilesystemModule.h](../../../../src/core/FilesystemModule.h) diff --git a/docs/moonmodules/core/FirmwareUpdateModule.md b/docs/moonmodules/core/archive/FirmwareUpdateModule.md similarity index 97% rename from docs/moonmodules/core/FirmwareUpdateModule.md rename to docs/moonmodules/core/archive/FirmwareUpdateModule.md index 0d7e9838..543094bf 100644 --- a/docs/moonmodules/core/FirmwareUpdateModule.md +++ b/docs/moonmodules/core/archive/FirmwareUpdateModule.md @@ -1,6 +1,6 @@ # FirmwareUpdateModule -![FirmwareUpdateModule controls](../../assets/core/FirmwareUpdateModule.png) +![FirmwareUpdateModule controls](../../../assets/core/FirmwareUpdateModule.png) A thin status surface for OTA flashing. The flash itself is driven by `POST /api/firmware/url` in HttpServerModule, which hands the URL to `platform::http_fetch_to_ota` (a task that downloads via `esp_https_ota` and writes the next OTA partition). The task and this module communicate through shared file-scope globals; the module polls them in `loop1s()` and the existing WebSocket state push surfaces the change at 1 Hz. @@ -68,4 +68,4 @@ After an error, the status slot stays on the error message until the next `/api/ ## Source -[FirmwareUpdateModule.h](../../../src/core/FirmwareUpdateModule.h) +[FirmwareUpdateModule.h](../../../../src/core/FirmwareUpdateModule.h) diff --git a/docs/moonmodules/core/HttpServerModule.md b/docs/moonmodules/core/archive/HttpServerModule.md similarity index 91% rename from docs/moonmodules/core/HttpServerModule.md rename to docs/moonmodules/core/archive/HttpServerModule.md index f9769199..936fca3b 100644 --- a/docs/moonmodules/core/HttpServerModule.md +++ b/docs/moonmodules/core/archive/HttpServerModule.md @@ -2,7 +2,7 @@ Embedded HTTP server + WebSocket. Serves the web UI and the REST API that backs it. -> This page is the end-user / API-integrator view of the module. The C++ interface lives in [`src/core/HttpServerModule.h`](../../../src/core/HttpServerModule.h) (+ `.cpp`); facts visible there (private helpers, member layout, lifecycle methods) aren't repeated here. See [CLAUDE.md § Documentation](../../../CLAUDE.md) for the rule. +> This page is the end-user / API-integrator view of the module. The C++ interface lives in [`src/core/HttpServerModule.h`](../../../../src/core/HttpServerModule.h) (+ `.cpp`); facts visible there (private helpers, member layout, lifecycle methods) aren't repeated here. See [CLAUDE.md § Documentation](../../../../CLAUDE.md) for the rule. Controls: `port` (uint16_t, default 8080 on desktop / 80 on ESP32). @@ -58,7 +58,7 @@ All JSON responses stream through a `JsonSink` — no fixed-buffer ceiling, so a - **Resumable buffered send** — `sendBufferedFrame(header, headerLen, body, bodyLen)`: for a payload that lives in a **stable caller-owned buffer** (PreviewDriver's full-res colour frame, whose body is the driver buffer). The header is copied; `body` is a pointer the caller keeps stable. One WS message is then **drained a memory-adaptive chunk per client per `loop20ms`** via `writeSome` — so a large frame is delivered over wall-clock ticks **without spinning any loop**, yet stays one atomic WS message to the browser. One send in flight at a time: a new `sendBufferedFrame` while one is active is **dropped** (newest-wins backpressure → the producer reads "link busy"). `bufferedSendIdle()` reports when the previous frame finished draining; `cancelBufferedSend()` abandons an in-flight send before its `body` is freed (a geometry rebuild). The chunk size comes from `maxAllocBlock()` so a tight board takes small bites (bounded tick cost) and a roomy board drains fast. - **Client → server:** none. Mutations go through the REST API. -Both paths are domain-neutral (the server doesn't interpret the bytes). The resumable drain runs on **`loop20ms` (the 20 ms transport-poll), deliberately NOT the per-render-tick `loop()`** — pushing preview bytes to the socket must not be charged to the LED render hot path. The LED path (the driver output) is never delayed by the preview; the preview frame rate is instead bounded by the 20 ms drain cadence (a few fps at large full-res frames, higher for small grids), which is the right trade since the preview is a *view* and the LEDs are not. The resumable path lets a 128²+ full-res frame stream on a slow link without stalling the device: the effective frame rate self-limits (the next frame waits for `bufferedSendIdle()`), so the link sheds frame rate gracefully instead of freezing. When the two-core render/transport split lands ([architecture.md § Parallelism](../../architecture.md#parallelism)) the drain moves to the transport core and the cadence limit lifts — `loop20ms` is already that seam. +Both paths are domain-neutral (the server doesn't interpret the bytes). The resumable drain runs on **`loop20ms` (the 20 ms transport-poll), deliberately NOT the per-render-tick `loop()`** — pushing preview bytes to the socket must not be charged to the LED render hot path. The LED path (the driver output) is never delayed by the preview; the preview frame rate is instead bounded by the 20 ms drain cadence (a few fps at large full-res frames, higher for small grids), which is the right trade since the preview is a *view* and the LEDs are not. The resumable path lets a 128²+ full-res frame stream on a slow link without stalling the device: the effective frame rate self-limits (the next frame waits for `bufferedSendIdle()`), so the link sheds frame rate gracefully instead of freezing. When the two-core render/transport split lands ([architecture.md § Parallelism](../../../architecture.md#parallelism)) the drain moves to the transport core and the cadence limit lifts — `loop20ms` is already that seam. ## WLED-compatibility shim @@ -77,18 +77,10 @@ HttpServerModule is core infrastructure with **no** light-domain dependencies ## Prior art -### projectMM v1 — HttpServer + WsServer ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/core/HttpServer.h)) - -HTTP via cpp-httplib (PC) / ESPAsyncWebServer (ESP32). WebSocket on separate port 81. - -### projectMM v2 — HttpServerModule + WebSocketModule ([source](https://github.com/ewowi/projectMM-v2/blob/main/src/modules/network/HttpServerModule.h)) - -Separate MoonModules for HTTP and WebSocket. projectMM combines them into one module. - ### WLED native app — [WLED-Android](https://github.com/Moustachauve/WLED-Android) by Christophe Gagnier ([@Moustachauve](https://github.com/Moustachauve)) The WLED-compatibility shim's exact field requirements were reverse-engineered from this client's source: `DeviceDiscovery.kt` (mDNS `_wled._tcp` browse), `DeviceFirstContactService.kt` (the `/json/info` validation + non-empty `mac` check), the `Info`/`State` Moshi models (the non-nullable `name`/`leds`/`wifi` fields that gate acceptance), and `WebsocketClient.kt` (live state over `/ws`, the `sendState` control direction). Credit to @Moustachauve — knowing precisely what the app reads is why the shim is the minimal accepted object rather than a guessed full WLED emulation. ## Source -[HttpServerModule.cpp](../../../src/core/HttpServerModule.cpp) · [HttpServerModule.h](../../../src/core/HttpServerModule.h) +[HttpServerModule.cpp](../../../../src/core/HttpServerModule.cpp) · [HttpServerModule.h](../../../../src/core/HttpServerModule.h) diff --git a/docs/moonmodules/core/I2cScanModule.md b/docs/moonmodules/core/archive/I2cScanModule.md similarity index 91% rename from docs/moonmodules/core/I2cScanModule.md rename to docs/moonmodules/core/archive/I2cScanModule.md index 92748034..f7db6711 100644 --- a/docs/moonmodules/core/I2cScanModule.md +++ b/docs/moonmodules/core/archive/I2cScanModule.md @@ -2,7 +2,7 @@ A **core**, domain-neutral diagnostic that scans an I2C bus and reports which device addresses ACK — the standard [`i2cdetect`](https://manpages.debian.org/i2c-tools/i2cdetect.8.en.html) operation, surfaced in the UI. It is the bring-up tool for any I2C peripheral (an audio codec, a sensor, a port expander): set the bus pins, press scan, read off the addresses present. Confirms wiring before a driver tries to talk to the device. -Not auto-wired. Factory-registered like [AudioModule](AudioModule.md), so a board with an I2C bus adds it through `docs/install/deviceModels.json` (its `sda`/`scl` controls carry that board's bus pins) or the user adds it from the UI. +Not auto-wired. Factory-registered like [AudioModule](AudioModule.md), so a board with an I2C bus adds it through `web-installer/deviceModels.json` (its `sda`/`scl` controls carry that board's bus pins) or the user adds it from the UI. ## Controls @@ -24,4 +24,4 @@ The bus-scan-as-a-feature mirrors MoonLight's I2C scan diagnostic; the seam name ## Source -[I2cScanModule.h](../../../src/core/I2cScanModule.h) +[I2cScanModule.h](../../../../src/core/I2cScanModule.h) diff --git a/docs/moonmodules/core/ImprovProvisioningModule.md b/docs/moonmodules/core/archive/ImprovProvisioningModule.md similarity index 94% rename from docs/moonmodules/core/ImprovProvisioningModule.md rename to docs/moonmodules/core/archive/ImprovProvisioningModule.md index 8ccf4643..93d73196 100644 --- a/docs/moonmodules/core/ImprovProvisioningModule.md +++ b/docs/moonmodules/core/archive/ImprovProvisioningModule.md @@ -4,9 +4,9 @@ Browser-driven WiFi provisioning over USB-serial, using the [Improv-WiFi](https://www.improv-wifi.com/) protocol. Bridges credentials from a Chrome / Edge / Opera tab — or from `scripts/build/improv_provision.py` for rack/CI use — into `NetworkModule::setWifiCredentials`, which writes the same buffers the AP-fallback UI flow uses. The protocol parser + UART task live in the platform layer; this module is the status surface that polls a ready-flag and bridges credentials to NetworkModule on the scheduler thread. -A code-wired child of NetworkModule. The wiring calls `markWiredByCode()` so the persistence-apply step preserves the child across reboots even on devices whose `Network.json` predates the addition (see [Persistence — code-wired children](../../architecture.md#persistence)). +A code-wired child of NetworkModule. The wiring calls `markWiredByCode()` so the persistence-apply step preserves the child across reboots even on devices whose `Network.json` predates the addition (see [Persistence — code-wired children](../../../architecture.md#persistence)). -The browser flow runs immediately after a Web Serial flash (ESP Web Tools recognises Improv-capable firmware and offers a "Connect to Wi-Fi?" dialog automatically). The CLI flow uses [`scripts/build/improv_provision.py`](../../../scripts/build/improv_provision.py) over the same USB cable for headless or rack provisioning. +The browser flow runs immediately after a Web Serial flash (ESP Web Tools recognises Improv-capable firmware and offers a "Connect to Wi-Fi?" dialog automatically). The CLI flow uses [`scripts/build/improv_provision.py`](../../../../scripts/build/improv_provision.py) over the same USB cable for headless or rack provisioning. ## Controls @@ -20,7 +20,13 @@ The listener serves **both** serial transports: UART0 (external USB-to-UART brid ## 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 standard RPC commands plus two vendor extensions: +Both transports speak the same Improv-WiFi serial protocol — frames of `IMPROV` + version byte + type + length + payload + checksum. Full protocol details: . The magic, version, payload cap, and frame types come straight from the device source (this is the authority — edit the header, the doc follows): + +```cpp +--8<-- "src/core/ImprovFrame.h:frame-constants" +``` + +The on-device implementation supports four standard RPC commands plus two vendor extensions: - `GET_CURRENT_STATE` — returns "authorized" or "provisioned" depending on whether WiFi STA is connected. - `GET_DEVICE_INFO` — returns `[firmware, version, chipFamily, deviceName]` (where `firmware` = `"projectMM"`, `version` from `kVersion` in `build_info.h`, `chipFamily` from `platform::chipModel()`, `deviceName` from `SystemModule`). @@ -75,4 +81,4 @@ done ## Source -[ImprovProvisioningModule.h](../../../src/core/ImprovProvisioningModule.h) +[ImprovProvisioningModule.h](../../../../src/core/ImprovProvisioningModule.h) diff --git a/docs/moonmodules/core/MoonModule.md b/docs/moonmodules/core/archive/MoonModule.md similarity index 84% rename from docs/moonmodules/core/MoonModule.md rename to docs/moonmodules/core/archive/MoonModule.md index c15995a8..72405cfd 100644 --- a/docs/moonmodules/core/MoonModule.md +++ b/docs/moonmodules/core/archive/MoonModule.md @@ -48,7 +48,7 @@ Every MoonModule has a dynamic children array. `addChild()`, `removeChild()`, `r Children are distinguished by `role()` (Effect, Modifier, Driver, Layout, Generic). Containers that need role-specific iteration (e.g. Layer::loop() only calls loop() on Effects, not Modifiers) filter children by role at the call site. -Two virtuals govern UI tree-mutation, keeping that policy on the device rather than hardcoded in the web UI (see [architecture.md § Web UI](../../architecture.md#web-ui)): `acceptsChildRoles()` — comma-separated roles this module accepts as user-added children (`""` default; a container like Layer returns `"effect,modifier"`), surfaced per-type in `/api/types`, drives the UI's `+ add child` affordance and picker filter. `userEditable()` — whether the user may delete/replace this module (`true` default; a load-bearing child like PreviewDriver returns `false`), surfaced per-instance in `/api/state` (emitted only when false). The `+ add child` policy lives on the parent; the deletable/replaceable policy lives on the child. +Two virtuals govern UI tree-mutation, keeping that policy on the device rather than hardcoded in the web UI (see [architecture.md § Web UI](../../../architecture.md#web-ui)): `acceptsChildRoles()` — comma-separated roles this module accepts as user-added children (`""` default; a container like Layer returns `"effect,modifier"`), surfaced per-type in `/api/types`, drives the UI's `+ add child` affordance and picker filter. `userEditable()` — whether the user may delete/replace this module (`true` default; a load-bearing child like PreviewDriver returns `false`), surfaced per-instance in `/api/state` (emitted only when false). The `+ add child` policy lives on the parent; the deletable/replaceable policy lives on the child. Parents own their children's lifecycle. Only top-level modules are registered with the Scheduler — parents propagate `setup()`, `onBuildControls()`, `onBuildState()`, `loop()`, `loop20ms()`, `loop1s()`, and `teardown()` to their children. This means children don't need separate Scheduler registration. @@ -76,7 +76,7 @@ Conditional controls (e.g. fields only visible under a Select mode) are always b ## Tests -[Unit tests: MoonModule](../../tests/unit-tests.md#moonmodule) — lifecycle, control binding, clear and rebuild. +[Unit tests: MoonModule](../../../tests/unit-tests.md#moonmodule) — lifecycle, control binding, clear and rebuild. ## Prior art @@ -87,18 +87,6 @@ Conditional controls (e.g. fields only visible under a Select mode) are always b - `addControl()` binds to class variable by reference, stores `uintptr_t` pointer. - `classSize()` reports actual instance size. -### projectMM v1 — StatefulModule ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/core/StatefulModule.h)) - -- Same addControl-by-reference pattern. - -### projectMM v2 — MoonModule ([source](https://github.com/ewowi/projectMM-v2/blob/main/src/core/MoonModule.h)) - -- `onBuildControls()` / `onBuildState()` separation. -- `onChildrenReady()` — parent-notified-after-children hook. Not carried over; child setup ordering is handled by Scheduler's 4-phase boot instead. -- Field order optimized 8B→4B→2B→1B, saving 24 bytes. -- `classSize` set via `register_type()`. -- `AutoWireSpec` — an arbitrary dependency-graph (DAG) wiring mechanism. projectMM deliberately uses parent/child only; the DAG was more than the domain needs. - ## Source -[MoonModule.h](../../../src/core/MoonModule.h) +[MoonModule.h](../../../../src/core/MoonModule.h) diff --git a/docs/moonmodules/core/NetworkModule.md b/docs/moonmodules/core/archive/NetworkModule.md similarity index 93% rename from docs/moonmodules/core/NetworkModule.md rename to docs/moonmodules/core/archive/NetworkModule.md index afa62b45..3f5e81b2 100644 --- a/docs/moonmodules/core/NetworkModule.md +++ b/docs/moonmodules/core/archive/NetworkModule.md @@ -1,6 +1,6 @@ # NetworkModule -![NetworkModule controls](../../assets/core/NetworkModule.png) +![NetworkModule controls](../../../assets/core/NetworkModule.png) Manages all device connectivity with automatic fallback: Ethernet → WiFi STA → WiFi AP. One MoonModule, one UI card — the user sees "Network", not three separate technologies. @@ -28,7 +28,7 @@ When a higher-priority connection becomes available, lower ones are torn down to - When Static: `ip`, `gateway`, `subnet`, `dns` (ipv4 controls — 4 bytes of storage each, not 16-char strings; the wire shape is still a dotted-quad string). Shown dynamically via onBuildControls. - `mDNS` (bool) — enable/disable mDNS responder -**Ethernet PHY/pin controls** (only on builds with an Ethernet driver — `platform::hasEthernet`). The PHY *driver* is compiled into the firmware per chip (internal-EMAC RMII on classic/P4, W5500 SPI on the S3); these controls pick *which* PHY a board uses and *on which pins* — runtime config, set per board in [`deviceModels.json`](../../install/deviceModels.json) (→ `setEthConfig` → `ethInit`), seeded from the per-chip default in `platform_config.h`. `ethType` is the switch: with it at 0 no pin rows show; choosing a type reveals only that type's pins (RMII rows for LAN8720/IP101, SPI rows for W5500). A W5500 change applies **live** (the SPI driver tears down + re-inits, no reboot); an RMII change saves and applies on the next boot (status hints "restart to apply"). See [architecture.md § Config provenance](../../architecture.md#config-provenance-mcu--board--device). +**Ethernet PHY/pin controls** (only on builds with an Ethernet driver — `platform::hasEthernet`). The PHY *driver* is compiled into the firmware per chip (internal-EMAC RMII on classic/P4, W5500 SPI on the S3); these controls pick *which* PHY a board uses and *on which pins* — runtime config, set per board in [`deviceModels.json`](../../../../web-installer/deviceModels.json) (→ `setEthConfig` → `ethInit`), seeded from the per-chip default in `platform_config.h`. `ethType` is the switch: with it at 0 no pin rows show; choosing a type reveals only that type's pins (RMII rows for LAN8720/IP101, SPI rows for W5500). A W5500 change applies **live** (the SPI driver tears down + re-inits, no reboot); an RMII change saves and applies on the next boot (status hints "restart to apply"). See [architecture.md § Config provenance](../../../architecture.md#config-provenance-mcu-devicemodel). - `ethType` (select) — PHY type dropdown, options `None` / `LAN8720` / `IP101` / `W5500` (stored as the index 0..3, matching the `EthPhyType` enum: 0 = none, 1 = LAN8720 RMII, 2 = IP101 RMII, 3 = W5500 SPI). - `ethPhyAddr` (pin) — SMI/PHY address (typically 0 or 1). - `ethRstGpio` (pin) — PHY reset GPIO (−1 = none / module self-resets). @@ -67,7 +67,7 @@ The web installer reads this token from the device's boot serial log right after ## Ethernet -Ethernet init is part of `NetworkModule::setup()`, which calls `syncEthConfig()` (pushes the eth controls above into `platform::setEthConfig`) then `platform::ethInit()`. The PHY type and pins are **runtime** config (the `ethType` + pin controls above), not compile-time — see the controls list and [architecture.md § Config provenance](../../architecture.md#config-provenance-mcu--board--device). +Ethernet init is part of `NetworkModule::setup()`, which calls `syncEthConfig()` (pushes the eth controls above into `platform::setEthConfig`) then `platform::ethInit()`. The PHY type and pins are **runtime** config (the `ethType` + pin controls above), not compile-time — see the controls list and [architecture.md § Config provenance](../../../architecture.md#config-provenance-mcu-devicemodel). Which Ethernet *driver* is compiled in is per chip (the firmware variant): classic/P4 carry the internal-EMAC RMII driver, the S3 the W5500 SPI driver; a build with no Ethernet driver (`MM_NO_ETH`) stubs `ethInit()` to return false. When no PHY responds (no cable, or no hardware), `ethInit()` returns false and the cascade falls through to WiFi STA → AP — no GPIO grab, no hang. @@ -155,13 +155,6 @@ The `ssid_` / `password_` member buffers still exist (unconditional struct layou ## Prior art -### projectMM v1 - -- `Network.h` — mode selection (STA/AP/OFF) -- `WifiSta.h` — STA connection with timeout + fallback -- `WifiAp.h` — soft AP setup -- `DeviceDiscovery.h` — UDP broadcast (separate module) - ### MoonLight - mDNS hostname advertising @@ -177,4 +170,4 @@ The `ssid_` / `password_` member buffers still exist (unconditional struct layou ## Source -[NetworkModule.h](../../../src/core/NetworkModule.h) +[NetworkModule.h](../../../../src/core/NetworkModule.h) diff --git a/docs/moonmodules/core/Scheduler.md b/docs/moonmodules/core/archive/Scheduler.md similarity index 69% rename from docs/moonmodules/core/Scheduler.md rename to docs/moonmodules/core/archive/Scheduler.md index eaf5001d..9b1627f3 100644 --- a/docs/moonmodules/core/Scheduler.md +++ b/docs/moonmodules/core/archive/Scheduler.md @@ -29,15 +29,6 @@ Child modules run in their declared order within the parent. Top-level modules a - Two FreeRTOS tasks: effects on core 1, system/drivers on core 0. - Per-node: `loop()` every frame, `loop20ms()` for slow updates. -### projectMM v1 — Scheduler ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/core/Scheduler.h)) - -- Time-sliced dispatch: setup, loop, loop20ms, loop1s for all modules. Single-threaded. - -### projectMM v2 — Scheduler ([source](https://github.com/ewowi/projectMM-v2/blob/main/src/core/Scheduler.h)) - -- Multi-core: runs N `core_loop()` threads (default 2 on ESP32). Each module declares `coreAffinity()`. Uses `pal::task_create_pinned` with 8KB stacks. -- Module ticking: setup, loop, loop20ms, loop1s, loop10s, teardown — dispatched per core. - ## Source -[Scheduler.cpp](../../../src/core/Scheduler.cpp) · [Scheduler.h](../../../src/core/Scheduler.h) +[Scheduler.cpp](../../../../src/core/Scheduler.cpp) · [Scheduler.h](../../../../src/core/Scheduler.h) diff --git a/docs/moonmodules/core/SystemModule.md b/docs/moonmodules/core/archive/SystemModule.md similarity index 75% rename from docs/moonmodules/core/SystemModule.md rename to docs/moonmodules/core/archive/SystemModule.md index 28610e1c..fee40610 100644 --- a/docs/moonmodules/core/SystemModule.md +++ b/docs/moonmodules/core/archive/SystemModule.md @@ -1,6 +1,6 @@ # SystemModule -![SystemModule controls](../../assets/core/SystemModule.png) +![SystemModule controls](../../../assets/core/SystemModule.png) System-level diagnostics and device identity. Always loaded, always visible in the UI. @@ -16,7 +16,7 @@ System-level diagnostics and device identity. Always loaded, always visible in t **Configurable:** - `deviceName` (text, default `MM-XXXX` where XXXX = last 4 hex of MAC) — the device's network identity (*which unit this is*). Used as hostname for mDNS, AP SSID, and UI display. Persisted. -- `deviceModel` (text, read-only in the UI) — the physical-hardware identity (*which product this is*, e.g. `Olimex ESP32-Gateway Rev G`), the entry name from the device-model catalog ([deviceModels.json](../../install/deviceModels.json)). The device can't self-identify its hardware, so this is *pushed* by tooling — just like any other catalog default: the web installer sends it as one of the `APPLY_OP` `set` ops during provisioning (see [ImprovProvisioningModule.md](ImprovProvisioningModule.md)), or MoonDeck over HTTP `/api/control` on the LAN. The printable-ASCII rule (1..31 chars, 0x20–0x7E, no NUL) is a per-control validator on the descriptor (`ControlDescriptor::validate`), so *every* write path — HTTP, serial APPLY_OP, persistence load — runs it in the backend. Display-only in the UI (pushed, never user-typed at the device); persisted. +- `deviceModel` (text, read-only in the UI) — the physical-hardware identity (*which product this is*, e.g. `Olimex ESP32-Gateway Rev G`), the entry name from the device-model catalog ([deviceModels.json](../../../../web-installer/deviceModels.json)). The device can't self-identify its hardware, so this is *pushed* by tooling — just like any other catalog default: the web installer sends it as one of the `APPLY_OP` `set` ops during provisioning (see [ImprovProvisioningModule.md](ImprovProvisioningModule.md)), or MoonDeck over HTTP `/api/control` on the LAN. The printable-ASCII rule (1..31 chars, 0x20–0x7E, no NUL) is a per-control validator on the descriptor (`ControlDescriptor::validate`), so *every* write path — HTTP, serial APPLY_OP, persistence load — runs it in the backend. Display-only in the UI (pushed, never user-typed at the device); persisted. **Static (set at boot):** - `chip` (read-only) — chip model (ESP32, ESP32-S3, etc.) @@ -39,11 +39,6 @@ On desktop these show "desktop" / "N/A" for hardware-specific fields. ## Prior art -### projectMM v1 - -- System info displayed in web UI (heap, FPS, chip info) -- Device name configurable and persisted - ### MoonLight - System diagnostics via REST API @@ -51,4 +46,4 @@ On desktop these show "desktop" / "N/A" for hardware-specific fields. ## Source -[SystemModule.h](../../../src/core/SystemModule.h) +[SystemModule.h](../../../../src/core/SystemModule.h) diff --git a/docs/moonmodules/core/ui.md b/docs/moonmodules/core/archive/ui.md similarity index 96% rename from docs/moonmodules/core/ui.md rename to docs/moonmodules/core/archive/ui.md index 653ee798..ec7440fa 100644 --- a/docs/moonmodules/core/ui.md +++ b/docs/moonmodules/core/archive/ui.md @@ -128,14 +128,14 @@ Auto-rendered by `controls[].type`. Adding a new MoonModule with these control t The same picker serves two purposes: **add** (triggered by `+ add child`) and **replace** (triggered by the ✎ button on a card). Renders inline inside the card (not a modal). - **Role filter**: in add mode, filters to the parent's `acceptsChildRoles` (the device declares it per-type in `/api/types` — the UI hardcodes no container→role mapping). In replace mode, filters to the target module's own role. -- **Emoji tag chips**: a row of toggle chips above the list, one per distinct emoji across the role-filtered types. Each type's emoji set has three sources, in this order: a **role chip** (derived in the UI from `role`), a **dimensional chip** (derived in the UI from `dim` when the type declares one — 1/2/3 means 1D/2D/3D), and the curated **`tags`** string from `/api/types` (the module's `tags()` — a flash string literal). The UI treats `tags` as opaque: it splits the string into grapheme clusters and renders each as a chip. The domain that owns this UI assigns each emoji's meaning — see the domain's own architecture page for the assignments (e.g. [architecture.md § Web UI](../../architecture.md#web-ui) for the role / dim / origin / creator / audio / moving-head assignments used by the light domain shipped today). Toggling chips narrows the list with **AND** logic: a type shows only if it carries every active chip. Each list row shows the type's emoji before its name. +- **Emoji tag chips**: a row of toggle chips above the list, one per distinct emoji across the role-filtered types. Each type's emoji set has three sources, in this order: a **role chip** (derived in the UI from `role`), a **dimensional chip** (derived in the UI from `dim` when the type declares one — 1/2/3 means 1D/2D/3D), and the curated **`tags`** string from `/api/types` (the module's `tags()` — a flash string literal). The UI treats `tags` as opaque: it splits the string into grapheme clusters and renders each as a chip. The domain that owns this UI assigns each emoji's meaning — see the domain's own architecture page for the assignments (e.g. [architecture.md § Web UI](../../../architecture.md#web-ui) for the role / dim / origin / creator / audio / moving-head assignments used by the light domain shipped today). Toggling chips narrows the list with **AND** logic: a type shows only if it carries every active chip. Each list row shows the type's emoji before its name. - **Search box** with substring match on type name. Search and chips combine (both must match). - **Keyboard nav**: type to filter, ↓ to enter list, ↑↓ to move, Enter to confirm, Esc to cancel. - **Confirm / Cancel** action buttons at the bottom (the confirm button reads `create` or `replace` per mode). Double-click a row to confirm immediately. ## Module hierarchy -Each project pins a fixed top-level shape in its `main.cpp` — the side nav lists those roots in registration order, and the UI does **not** allow root reorder. System modules (Filesystem, System, Network, HttpServer) are always present; the domain modules (the actual data-flow pipeline) sit alongside them. For the light-domain shape see [architecture.md § Web UI](../../architecture.md#web-ui). +Each project pins a fixed top-level shape in its `main.cpp` — the side nav lists those roots in registration order, and the UI does **not** allow root reorder. System modules (Filesystem, System, Network, HttpServer) are always present; the domain modules (the actual data-flow pipeline) sit alongside them. For the light-domain shape see [architecture.md § Web UI](../../../architecture.md#web-ui). Child reorder *within* a parent (a child within a container) is supported via HTML5 drag-and-drop (desktop and mobile), which calls `POST /api/modules//move {to:N}`. @@ -145,7 +145,7 @@ Child reorder *within* a parent (a child within a container) is supported via HT - URL: `ws:///ws` (same port as HTTP) - Server pushes full state snapshot as JSON ~1/sec (same shape as `GET /api/state`). The JSON is built through a streaming sink with no fixed-size buffer — a module tree of any size serializes without truncation -- Server may push **binary frames** on the same socket. The first byte selects the frame type and dispatches to a domain renderer; the rest of the frame is the domain's choice. The UI ignores types it doesn't recognise. See [Domain preview channel](#domain-preview-channel) for the dispatching contract and the domain's own architecture page for the payload (e.g. [architecture.md § Web UI](../../architecture.md#web-ui)) +- Server may push **binary frames** on the same socket. The first byte selects the frame type and dispatches to a domain renderer; the rest of the frame is the domain's choice. The UI ignores types it doesn't recognise. See [Domain preview channel](#domain-preview-channel) for the dispatching contract and the domain's own architecture page for the payload (e.g. [architecture.md § Web UI](../../../architecture.md#web-ui)) - Client sends `"ping"` every 25s as keepalive (Safari kills idle sockets otherwise) - Auto-reconnect on close with exponential backoff (500ms → 5s ceiling) - Pause on `document.visibilityState === 'hidden'`; resume on `pageshow` (Safari bfcache survival) @@ -188,7 +188,7 @@ Generic shape: `[type-byte] [domain-specific header] [payload]`. The first byte - `touch-action: none` so single- and multi-finger gestures don't trigger native page scroll or pinch-zoom - WebGL clear color is `(0, 0, 0, 0)` — transparent canvas blends into either theme without per-frame color work -For the light-domain renderer (WebGL point cloud, frame format, orbit camera, downsampling) see [architecture.md § Web UI](../../architecture.md#web-ui). +For the light-domain renderer (WebGL point cloud, frame format, orbit camera, downsampling) see [architecture.md § Web UI](../../../architecture.md#web-ui). ## State updates — the no-rebuild contract @@ -219,4 +219,4 @@ No other client state persists. Reorder, control values, etc. all live on the de ## Source -The hand-maintained files: [index.html](../../../src/ui/index.html) · [app.js](../../../src/ui/app.js) · [style.css](../../../src/ui/style.css) · [preview3d.js](../../../src/ui/preview3d.js) · [install-picker.js](../../../src/ui/install-picker.js). Embedded into the firmware at build time via [embed_ui.cmake](../../../src/ui/embed_ui.cmake). +The hand-maintained files: [index.html](../../../../src/ui/index.html) · [app.js](../../../../src/ui/app.js) · [style.css](../../../../src/ui/style.css) · [preview3d.js](../../../../src/ui/preview3d.js) · [install-picker.js](../../../../src/ui/install-picker.js). Embedded into the firmware at build time via [embed_ui.cmake](../../../../src/ui/embed_ui.cmake). diff --git a/docs/moonmodules/core/supporting/supporting.md b/docs/moonmodules/core/supporting/supporting.md new file mode 100644 index 00000000..cf5eb446 --- /dev/null +++ b/docs/moonmodules/core/supporting/supporting.md @@ -0,0 +1,31 @@ +# Core supporting modules + +The core machinery the UI modules lean on — not directly user-facing, so no controls of their own. Each row links to its generated technical page (the full API, from the `.h`) and its tests. Cross-file design rationale that no single `.h` owns lives in the prose sections below the table. + +### Control + +A named, typed value a MoonModule exposes to the UI — the binding between a class variable and its web-UI widget, DMX channel, and persisted value. Every module's `controls_` is a list of these. + +Detail: [technical](../moxygen/Control.md) + +[Tests](../../../tests/unit-tests.md#moonmodule) + +### Scheduler + +Orders module `setup()` by declared init-order dependencies (WiFi before HTTP, HTTP before WebSocket) and drives the per-tick `loop()` sweep. The one place init-order lives, so modules declare a dependency instead of hard-coding boot sequence. + +Detail: [technical](../moxygen/Scheduler.md) + +[Tests](../../../tests/unit-tests.md#scheduler) + +### MoonModule + +The base class every module derives from — the shared lifecycle (`setup` / `loop` / `teardown`), the `controls_` list, child propagation, and the self-reporting footprint (`classSize` / `dynamicBytes` / `loopTimeUs`). Learn the pattern once, apply it everywhere. + +Detail: [technical](../moxygen/MoonModule.md) + +[Tests](../../../tests/unit-tests.md#moonmodule) + +## Persistence and dynamic rebuild + +Control values persist via [FilesystemModule](../moxygen/FilesystemModule.md), which overlays loaded values through each control's variable pointer during `onBuildControls()`. Calling `onBuildControls()` again at runtime (e.g. when a Select changes mode) clears and rebuilds the set, so only the controls relevant to the current mode show — this is how a control's conditional `hidden` flag re-evaluates. The rebuild sweep is also how a config change applies live, with no reboot. diff --git a/docs/moonmodules/core/ui/ui.md b/docs/moonmodules/core/ui/ui.md new file mode 100644 index 00000000..1d73c02e --- /dev/null +++ b/docs/moonmodules/core/ui/ui.md @@ -0,0 +1,92 @@ +# Core UI modules + +The user-facing core services — the machinery that runs a show, each configurable in the web UI. Every row links to its generated technical page (the full API, from the `.h`) and its tests. Cross-cutting rationale that no single `.h` owns lives in the prose sections below the table. + +### System + +The device's identity and vitals — name (behind mDNS `.local`, the SoftAP SSID, the DHCP hostname), uptime, heap, and per-module footprint reporting. Hosts the Audio and I2C-scan peripherals. + +- `deviceName` — the device identity behind mDNS `.local`, the SoftAP SSID, and the DHCP hostname. +- `deviceModel` — the board model (drives the installer catalog entry). +- read-only vitals — `uptime`, `fps`, `heap`, `psram`, `flash`, `chip`, and per-module footprint. + +Detail: [technical](../moxygen/SystemModule.md) + +[Tests](../../../tests/unit-tests.md#systemmodule) + +### Network + +WiFi / Ethernet connectivity, static-IP configuration, RSSI and TX-power reporting. Brings the device onto the LAN before the HTTP and WebSocket servers start. + +- `mode` — WiFi / Ethernet / off. +- `ssid` / `password` — WiFi credentials. +- `mDNS` — the `.local` hostname. +- `addressing` — DHCP or static; static exposes IP / gateway / subnet / DNS fields. +- `ethType` / `ethPhyAddr` / `ethRstGpio` / … — Ethernet PHY configuration. +- read-only — `rssi` (dBm), `txPower` (dBm). + +Detail: [technical](../moxygen/NetworkModule.md) + +[Tests](../../../tests/unit-tests.md#networkmodule) + +### Improv provisioning + +Serial/BLE Improv Wi-Fi provisioning — the web installer hands credentials to a fresh device over this protocol during the flash-and-connect flow. + +- `provision_status` — read-only provisioning state. + +Detail: [technical](../moxygen/ImprovProvisioningModule.md) + +### Devices + +Discovers and lists other projectMM devices on the LAN (the `devices` List control), each row expanding to a detail panel; persists the last-known list across reboot. + +- `devices` — a List control of discovered devices; each row expands to a detail panel. Persistable. + +Detail: [technical](../moxygen/DevicesModule.md) + +[Tests](../../../tests/unit-tests.md#devicesmodule) + +### Firmware update + +Over-the-air firmware flashing — the one operation that swaps the binary and needs a power cycle (every *config* change applies live; a firmware OTA does not). + +- `firmware` — the OTA image to flash. +- read-only — `version`, `build`, `firmwarePartition`, `update_pct` (progress). + +Detail: [technical](../moxygen/FirmwareUpdateModule.md) + +[Tests](../../../tests/unit-tests.md#firmwareupdatemodule) + +### Filesystem + +Persists control values as JSON and restores them on boot, overlaying loaded values through each control's pointer during `onBuildControls()`. The home of the no-reboot live-reconfiguration behaviour. + +- read-only — `lastSaved`, `filesystem` (usage). + +Detail: [technical](../moxygen/FilesystemModule.md) + +[Tests](../../../tests/unit-tests.md#filesystemmodule) + +### Audio + +A System peripheral (added by the user, not auto-wired): an I²S microphone feeding the FFT that audio-reactive effects consume via `AudioModule::latestFrame()`. Idle until real GPIOs are entered. + +- `wsPin` / `sdPin` / `sckPin` — the I²S microphone GPIOs (unset until entered). +- `sampleRate` — mic sample rate. +- `simulate` — feed a synthetic signal instead of the mic (for testing without hardware). +- read-only — `level` (RMS), `peakHz`. + +Detail: [technical](../moxygen/AudioModule.md) + +[Tests](../../../tests/unit-tests.md#audiomodule) + +### I2C scan + +A System peripheral that probes the I²C bus (default GPIO21/22) on a button press and reports the addresses found. + +- `sda` / `scl` — the bus GPIOs (default GPIO21/22). +- `scan` — a button; press to probe the bus now. +- read-only — `result` (addresses found). + +Detail: [technical](../moxygen/I2cScanModule.md) diff --git a/docs/moonmodules/light/BlendMap.md b/docs/moonmodules/light/archive/BlendMap.md similarity index 92% rename from docs/moonmodules/light/BlendMap.md rename to docs/moonmodules/light/archive/BlendMap.md index 11779a4c..2826d96f 100644 --- a/docs/moonmodules/light/BlendMap.md +++ b/docs/moonmodules/light/archive/BlendMap.md @@ -8,8 +8,8 @@ The combine math is integer-only (the hot-path per-light rule), with one tight s - **Additive**: `dst = clamp(dst + src·opacity)` — sum with saturation at 255, opacity scaling the source. - **Alpha** (over): `dst = (src·α + dst·(255−α)) / 255` — the textbook 8-bit alpha-over, division by 255 via the fast `(x + (x>>8) + 1) >> 8` reciprocal. Full opacity (255) collapses to a plain overwrite (no blend cost). -A dense-grid layer has no LUT, so its buffer blends 1:1 (source index = physical index, no lookup); a layer with a LUT maps each logical light to its physical destination(s) first. Physical indices come from the LUT, which is built in-range from the shared Layouts, so they address the buffer in bounds by construction. The per-Layer `blendMode`/`opacity` controls that select the op live on [Layer](Layer.md#blendmode--opacity-controls); Drivers reads them and the layer stack order. +A dense-grid layer has no LUT, so its buffer blends 1:1 (source index = physical index, no lookup); a layer with a LUT maps each logical light to its physical destination(s) first. Physical indices come from the LUT, which is built in-range from the shared Layouts, so they address the buffer in bounds by construction. The per-Layer `blendMode`/`opacity` controls that select the op live on [Layer](Layer.md#blendmode-opacity-controls); Drivers reads them and the layer stack order. ## Source -[BlendMap.h](../../../src/light/layers/BlendMap.h) +[BlendMap.h](../../../../src/light/layers/BlendMap.h) diff --git a/docs/moonmodules/light/Buffer.md b/docs/moonmodules/light/archive/Buffer.md similarity index 62% rename from docs/moonmodules/light/Buffer.md rename to docs/moonmodules/light/archive/Buffer.md index 6f8e8c34..a6cd40ff 100644 --- a/docs/moonmodules/light/Buffer.md +++ b/docs/moonmodules/light/archive/Buffer.md @@ -12,7 +12,7 @@ Semaphores are expensive (~150 bytes on ESP32), so prefer lock-free patterns: an ## Tests -[Unit tests: Buffer](../../tests/unit-tests.md#buffer) — allocate, clear, move semantics, double-free safety, zero-size edge case. +[Unit tests: Buffer](../../../tests/unit-tests.md#buffer) — allocate, clear, move semantics, double-free safety, zero-size edge case. ## Prior art @@ -20,14 +20,6 @@ Semaphores are expensive (~150 bytes on ESP32), so prefer lock-free patterns: an Raw `uint8_t*` buffer, sized by `channelsPerLight * nrOfLights`. Supports RGB, RGBW, and multi-channel DMX fixtures via LightsHeader offsets. -### projectMM v1 — Channel ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/layers/Channel.h)) - -3D array of RGB with width/height/depth metadata. `checksum()` method for verification in tests and health reporting. - -### projectMM v2 — DataBuffer ([source](https://github.com/ewowi/projectMM-v2/blob/main/src/core/DataBuffer.h)) - -Lock-free single-slot SPSC buffer with atomic revision counter. Teardown-safe via invalidate() sentinel. Multiple consumers each track independent read positions. - ## Source -[Buffer.h](../../../src/light/layers/Buffer.h) +[Buffer.h](../../../../src/light/layers/Buffer.h) diff --git a/docs/moonmodules/light/Drivers.md b/docs/moonmodules/light/archive/Drivers.md similarity index 82% rename from docs/moonmodules/light/Drivers.md rename to docs/moonmodules/light/archive/Drivers.md index 95413415..ef121924 100644 --- a/docs/moonmodules/light/Drivers.md +++ b/docs/moonmodules/light/archive/Drivers.md @@ -1,6 +1,6 @@ # Drivers -![Drivers controls](../../assets/light/drivers/Drivers.png) +![Drivers controls](../../../assets/light/drivers/Drivers.png) Top-level container for one or more drivers. The consumer side of the pipeline — owns the shared output buffer (when memory allows) and performs blend+map from every layer's buffer into it each frame. @@ -10,13 +10,13 @@ Top-level container for one or more drivers. The consumer side of the pipeline The shared output buffer is necessary because blend+map writes to arbitrary physical positions (via LUT) — the output is not filled sequentially. A driver cannot read chunk-by-chunk until the full buffer is populated. -Exception: when exactly one layer is enabled AND its mapping is 1:1 unshuffled (no LUT — grid layout, no serpentine), Drivers skips its own buffer and lets drivers read directly from the layer's buffer (the zero-copy fast path, at the cost of parallelism). See [architecture.md § Parallelism](../../architecture.md#parallelism). +Exception: when exactly one layer is enabled AND its mapping is 1:1 unshuffled (no LUT — grid layout, no serpentine), Drivers skips its own buffer and lets drivers read directly from the layer's buffer (the zero-copy fast path, at the cost of parallelism). See [architecture.md § Parallelism](../../../architecture.md#parallelism). It uses the same `Buffer` type a Layer does, sized by the Layouts container. ## Multi-layer composition -When two or more layers are enabled, Drivers composites them into the shared output buffer each frame, in [Layers](Layers.md) container order (bottom→top, via `forEachEnabledLayer`). The bottom layer clears and overwrites the buffer; each layer above blends onto the accumulated frame per its own `blendMode` and `opacity` (the inert per-Layer controls — see [Layer](Layer.md#blendmode--opacity-controls)). Drivers owns the orchestration because only it sees the stack order and the output buffer; the layers carry only the parameters. The per-pixel blend math lives in [BlendMap](BlendMap.md) (integer-only, per the hot-path rule). A full-opacity overwrite/additive layer pays no alpha arithmetic, so the per-frame cost scales with the enabled-layer count. With a single enabled layer there is no composite: the fast path above applies (no-LUT → zero-copy; with a LUT → one blend+map pass into the output buffer). +When two or more layers are enabled, Drivers composites them into the shared output buffer each frame, in [Layers](Layers.md) container order (bottom→top, via `forEachEnabledLayer`). The bottom layer clears and overwrites the buffer; each layer above blends onto the accumulated frame per its own `blendMode` and `opacity` (the inert per-Layer controls — see [Layer](Layer.md#blendmode-opacity-controls)). Drivers owns the orchestration because only it sees the stack order and the output buffer; the layers carry only the parameters. The per-pixel blend math lives in [BlendMap](BlendMap.md) (integer-only, per the hot-path rule). A full-opacity overwrite/additive layer pays no alpha arithmetic, so the per-frame cost scales with the enabled-layer count. With a single enabled layer there is no composite: the fast path above applies (no-LUT → zero-copy; with a LUT → one blend+map pass into the output buffer). ## Output correction @@ -28,7 +28,7 @@ The Drivers container owns the shared output-correction state and exposes two co | `lightPreset` | select | The physical wire format: channel order and whether the light is RGBW. Options: `RGB`, `RBG`, `GRB`, `GBR`, `BRG`, `BGR`, `RGBW`, `GRBW`. Defaults to `GRB` — the WS2812/SK6812 wire order, so a strip shows correct colours out of the box (PreviewDriver reads the RGB source buffer directly and is unaffected). RGBW presets make each driver emit 4 channels per light with white derived as `min(R,G,B)` from the (brightness-scaled) RGB. | | `palette` | select | The **global active colour palette** (`Rainbow`, `Party`, `Lava`, `Ocean`, …). Palette-driven effects read it via `Palettes::active()` and colour their pixels through `colorFromPalette(index)`, so changing this recolours every such effect live. The select index expands the chosen gradient into the active 16-entry palette on `onUpdate` (cheap, off the hot path). Drivers owns this as a global render parameter, alongside `brightness` and `lightPreset`. Palette model + names follow FastLED's, credited as prior art; implementation in `src/light/Palette.h`. | -The state lives on `Correction` (`src/light/drivers/Correction.h`): a brightness LUT, channel-order table, output channel count, derive-white flag. `Drivers::onUpdate` rebuilds it on a `brightness`/`lightPreset` change and hands each child a `const Correction*`. Every driver sees the same composited output; per-driver layer assignment (different drivers reading different layers) is a [backlog](../../backlog/README.md) item. +The state lives on `Correction` (`src/light/drivers/Correction.h`): a brightness LUT, channel-order table, output channel count, derive-white flag. `Drivers::onUpdate` rebuilds it on a `brightness`/`lightPreset` change and hands each child a `const Correction*`. Every driver sees the same composited output; per-driver layer assignment (different drivers reading different layers) is a [backlog](../../../backlog/README.md) item. ## Per-driver source window (`start` / `count`) @@ -49,14 +49,6 @@ The motivating case: an **onboard status LED** and a **main strip** as two drive Owns `channelsD` (display buffer). `compositeLayers()` maps virtualChannels → channelsD. Parallelism via semaphore: driver signals completion, compositor writes. -### projectMM v1 — DriverLayer ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/layers/DriverLayer.h)) - -Container for driver modules. Receives pixel data from EffectsLayer. - -### projectMM v2 — DataRegistry ([source](https://github.com/ewowi/projectMM-v2/blob/main/src/core/DataRegistry.h)) - -Type-erased buffer directory. Producers declare, consumers resolve by id. Decouples effects from drivers. - ## Source -[Drivers.h](../../../src/light/drivers/Drivers.h) +[Drivers.h](../../../../src/light/drivers/Drivers.h) diff --git a/docs/moonmodules/light/EffectBase.md b/docs/moonmodules/light/archive/EffectBase.md similarity index 75% rename from docs/moonmodules/light/EffectBase.md rename to docs/moonmodules/light/archive/EffectBase.md index 45393631..9fb7f5a5 100644 --- a/docs/moonmodules/light/EffectBase.md +++ b/docs/moonmodules/light/archive/EffectBase.md @@ -16,7 +16,7 @@ Animation is driven by **elapsed millis**, not frame count. This ensures consist ## Dimensions and auto-extrusion -`dimensions()` (D3 default; the `.h` documents the per-axis contract) is a claim about which axes the effect *iterates*, not what the layer has — so `loop()` must read `width()`/`height()`/`depth()` at frame time and never hardcode a bound. The `dim` int (1/2/3) is emitted in `/api/types`; the UI derives the 📏/🟦/🧊 chip from it, so it isn't repeated in each module's `tags()`. See [architecture.md § Effects](../../architecture.md#effects) for the live declaration per shipped effect, and `unit_Layer_extrude.cpp` for the pinned contract tests. +`dimensions()` (D3 default; the `.h` documents the per-axis contract) is a claim about which axes the effect *iterates*, not what the layer has — so `loop()` must read `width()`/`height()`/`depth()` at frame time and never hardcode a bound. The `dim` int (1/2/3) is emitted in `/api/types`; the UI derives the 📏/🟦/🧊 chip from it, so it isn't repeated in each module's `tags()`. See [architecture.md § Effects](../../../architecture.md#effects) for the live declaration per shipped effect, and `unit_Layer_extrude.cpp` for the pinned contract tests. ## Prior art @@ -26,14 +26,6 @@ Animation is driven by **elapsed millis**, not frame count. This ensures consist - Buffer access via `layer->virtualChannels` (raw byte array). - Time via `timeMicros()`. -### projectMM v1 — ProducerModule ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/core/ProducerModule.h)) - -Base for effects. Produces into a Channel. - -### projectMM v2 — PixelEffectBase ([source](https://github.com/ewowi/projectMM-v2/blob/main/src/modules/lights/effects/PixelEffectBase.h)) - -Shared spine: concrete effect implements only `build_effect_controls()` + `render_(px, w, h, d)`. Eliminates ~70 lines boilerplate. - ## Source -[EffectBase.h](../../../src/light/effects/EffectBase.h) +[EffectBase.h](../../../../src/light/effects/EffectBase.h) diff --git a/docs/moonmodules/light/Layer.md b/docs/moonmodules/light/archive/Layer.md similarity index 88% rename from docs/moonmodules/light/Layer.md rename to docs/moonmodules/light/archive/Layer.md index 843a1349..0934fe0d 100644 --- a/docs/moonmodules/light/Layer.md +++ b/docs/moonmodules/light/archive/Layer.md @@ -47,7 +47,7 @@ Lets a low-dimensional effect work on a higher-dimensional layer without per-eff Hot-path cost is zero for D3 effects (the default) and zero when the layer's unused axes are size 1 (a D2 effect on a 2D layer, a D1 effect on a 1D layer). Real `memcpy` work only happens when the layer has more dimensions than the effect writes — exactly the case the framework is meant to handle. -See [EffectBase § Dimensions and auto-extrusion](EffectBase.md#dimensions-and-auto-extrusion) for the effect-side contract and [Unit tests: Layer](../../tests/unit-tests.md#layer) for the pinned tests (see `unit_Layer_extrude.cpp`). +See [EffectBase § Dimensions and auto-extrusion](EffectBase.md#dimensions-and-auto-extrusion) for the effect-side contract and [Unit tests: Layer](../../../tests/unit-tests.md#layer) for the pinned tests (see `unit_Layer_extrude.cpp`). ## Status @@ -71,14 +71,6 @@ Consider whether Layer itself can provide the rendering context (buffer, dims, e - `nodes` vector for effects/modifiers (dynamic, not fixed-capacity). - `forEachLight` — per-logical-light iteration that asks the modifier for physical destinations; the LUT build uses the same per-light virtual-dispatch pattern. -### projectMM v1 — EffectsLayer ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/layers/EffectsLayer.h)) - -Container for effects. Owns Channel (pixel buffer). Effects wired via `setInput("layer", ...)`. - -### projectMM v2 — PixelEffectBase ([source](https://github.com/ewowi/projectMM-v2/blob/main/src/modules/lights/effects/PixelEffectBase.h)) - -Shared spine eliminates ~70 lines boilerplate per effect. Layout resolution by category, not type string. DataBuffer + DataRegistry for producer/consumer decoupling. - ## Source -[Layer.h](../../../src/light/layers/Layer.h) +[Layer.h](../../../../src/light/layers/Layer.h) diff --git a/docs/moonmodules/light/Layers.md b/docs/moonmodules/light/archive/Layers.md similarity index 89% rename from docs/moonmodules/light/Layers.md rename to docs/moonmodules/light/archive/Layers.md index 57c61ed9..ad8a9a02 100644 --- a/docs/moonmodules/light/Layers.md +++ b/docs/moonmodules/light/archive/Layers.md @@ -1,6 +1,6 @@ # Layers -![Layers controls](../../assets/core/Layers.png) +![Layers controls](../../../assets/core/Layers.png) Top-level container for one or more layers. Each layer renders independently into its own buffer; the Drivers container composes those buffers downstream. @@ -10,7 +10,7 @@ Top-level container for one or more layers. Each layer renders independently int Multi-layer composition (alpha-blend, additive, layered overlays) needs a place to walk every layer in order so drivers can merge their buffers before consuming the result. Layers is that place. With one layer inside it the container is a thin pass-through: `loop()` runs the single child and returns; behaviour is byte-identical to the single-layer pipeline. -The container owns no buffer: each layer owns its own, and the Drivers container owns the composited output. It wires the shared Layouts into every child so each can size its buffer. Two queries serve the Drivers compositor: `activeLayer()` (the first enabled child) answers physical dimensions and is the source for the single-layer fast path, and `forEachEnabledLayer(cb)` walks the enabled children in container order (bottom→top) — the order Drivers blends them, with `cb(layer, isFirst)` marking the bottom layer that clears the buffer. `enabledLayerCount()` lets Drivers pick the fast path (one enabled layer → hand its buffer straight to the driver) versus the composite path (≥2 → blend into the output buffer). The blend modes and the value-on-Layer / logic-in-Drivers split are documented on [Layer](Layer.md#blendmode--opacity-controls) and [Drivers](Drivers.md). +The container owns no buffer: each layer owns its own, and the Drivers container owns the composited output. It wires the shared Layouts into every child so each can size its buffer. Two queries serve the Drivers compositor: `activeLayer()` (the first enabled child) answers physical dimensions and is the source for the single-layer fast path, and `forEachEnabledLayer(cb)` walks the enabled children in container order (bottom→top) — the order Drivers blends them, with `cb(layer, isFirst)` marking the bottom layer that clears the buffer. `enabledLayerCount()` lets Drivers pick the fast path (one enabled layer → hand its buffer straight to the driver) versus the composite path (≥2 → blend into the output buffer). The blend modes and the value-on-Layer / logic-in-Drivers split are documented on [Layer](Layer.md#blendmode-opacity-controls) and [Drivers](Drivers.md). ## Prior art @@ -18,10 +18,6 @@ The container owns no buffer: each layer owns its own, and the Drivers container MoonLight's `PhysicalLayer` runs N `VirtualLayer`s and composites their buffers into the display channel. Same idea, different shape: Drivers (not Layers) does the compositing here. -### projectMM v1/v2 - -Single-layer designs. No prior container for multiple layers. - ## Source -[Layers.h](../../../src/light/layers/Layers.h) +[Layers.h](../../../../src/light/layers/Layers.h) diff --git a/docs/moonmodules/light/Layouts.md b/docs/moonmodules/light/archive/Layouts.md similarity index 93% rename from docs/moonmodules/light/Layouts.md rename to docs/moonmodules/light/archive/Layouts.md index bd31f33c..612d9480 100644 --- a/docs/moonmodules/light/Layouts.md +++ b/docs/moonmodules/light/archive/Layouts.md @@ -1,6 +1,6 @@ # Layouts -![Layouts controls](../../assets/core/Layouts.png) +![Layouts controls](../../../assets/core/Layouts.png) Top-level container for one or more layouts. Shared by every layer in the Layers container — defines the physical light topology of the installation. @@ -24,8 +24,8 @@ Layout children are reordered by drag-and-drop in the UI (`POST /api/modules//api//lights` → a JSON object keyed by light id (`{"1":{…},"2":{…}}`). A real bridge's response runs several KB; the driver scans it for colour-capable (`"hue"` in state) + reachable (`"reachable":true`) lights and maps window index → light id. - **List rooms** — `GET http:///api//groups` → a JSON object keyed by group id (`{"1":{"name":…,"lights":["1","2"],"type":"Room"},…}`). The driver scans it for `"type":"Room"` entries, reads each room's `name` and `lights` id array, and records which colour lights belong to each room (a bitmask over the colour-light list). This feeds the `room`/`light` dropdowns and the driven-set filter. - **Set a light** — `PUT http:///api//lights//state` with `{"on":true,"bri":0-254,"hue":0-65535,"sat":0-254,"transitiontime":N}` (or `{"on":false,…}` when the pixel is black). `hue`/`sat`/`bri` come from a textbook integer RGB→HSV of the pixel; `transitiontime` (deciseconds) is the cadence-matched fade. - -## Prior art - -The [Hue v1 CLIP API](https://developers.meethue.com/develop/hue-api/) (link-button pairing, `/lights//state`, `transitiontime`). The effect-as-output mapping (bulbs as pixels of the render buffer, driven through the window) is projectMM's own — the same shape its UDP and LED drivers use. - -## Source - -[HueDriver.h](../../../../src/light/drivers/HueDriver.h) diff --git a/docs/moonmodules/light/drivers/LcdLedDriver.md b/docs/moonmodules/light/drivers/LcdLedDriver.md index 849782a7..28466fa2 100644 --- a/docs/moonmodules/light/drivers/LcdLedDriver.md +++ b/docs/moonmodules/light/drivers/LcdLedDriver.md @@ -1,6 +1,6 @@ # LCD LED Driver -Overview and controls: [drivers.md § LCD LED](drivers.md#lcdled). This page carries the reference detail a control list can't — 3-slot-per-bit wire contract, buffer slicing, DMA memory sizing, and cross-domain wiring. +Overview, controls, prior art, source, and tests: [drivers.md § LED output](drivers.md#lcdled). This page carries *only* what no single source file can: the 3-slot-per-bit wire contract, buffer slicing, DMA memory sizing, and cross-domain wiring. ## Wire contract — 3 slots per bit @@ -18,21 +18,4 @@ One internal-RAM DMA frame buffer owned by the platform (PSRAM is deliberately n ## Cross-domain wiring -The driver is added as a child of the `Drivers` container at runtime via the catalog (`POST /api/modules`, a board's [`deviceModels.json`](../../../install/deviceModels.json) `modules` entry) — not boot-wired, so it only exists on a board that selects it. The type is registered on every target, but the peripheral exists only where the SOC has the LCD_CAM i80 bus (the S3 among current targets): on a chip without it the driver is inert (`lanesAvailable()` is 0, so init / loopback report "not supported on this platform"), so a board entry only lists `LcdLedDriver` where it makes sense. Once added, `Drivers::passBufferToDrivers` wires it like any child. The **slot encode** (`LcdSlots.h`) is domain code, host-testable; the **peripheral** (`platform_esp32_lcd.cpp`, ESP-IDF's [`esp_lcd` i80 bus](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-reference/peripherals/lcd/index.html) + GDMA) is the only IDF-touching part. - -## 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. -- **`loopbackTxPin` control (CI, host):** the conditional control — bound always, shown only while `loopbackTest` is on. The lane-0 override mechanism is shared with the Parlio driver (same `ParallelLedDriver` base) and hardware-verified there; the LCD hardware path itself is exercised by the loopback self-test above. The catalog-add path is verified on the sibling RMT/Parlio drivers (S3 boards currently default to RMT — LcdLed needs all 8 lanes). -- **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) +The driver is added as a child of the `Drivers` container at runtime via the catalog (`POST /api/modules`, a board's [`deviceModels.json`](../../../../web-installer/deviceModels.json) `modules` entry) — not boot-wired, so it only exists on a board that selects it. The type is registered on every target, but the peripheral exists only where the SOC has the LCD_CAM i80 bus (the S3 among current targets): on a chip without it the driver is inert (`lanesAvailable()` is 0, so init / loopback report "not supported on this platform"), so a board entry only lists `LcdLedDriver` where it makes sense. Once added, `Drivers::passBufferToDrivers` wires it like any child. The **slot encode** (`LcdSlots.h`) is domain code, host-testable; the **peripheral** (`platform_esp32_lcd.cpp`, ESP-IDF's [`esp_lcd` i80 bus](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-reference/peripherals/lcd/index.html) + GDMA) is the only IDF-touching part. diff --git a/docs/moonmodules/light/drivers/NetworkSendDriver.md b/docs/moonmodules/light/drivers/NetworkSendDriver.md index 4103bf61..e9dae14c 100644 --- a/docs/moonmodules/light/drivers/NetworkSendDriver.md +++ b/docs/moonmodules/light/drivers/NetworkSendDriver.md @@ -1,6 +1,6 @@ # Network Send Driver -Overview and controls: [drivers.md § Network Send](drivers.md#networksend). This page carries the reference detail a control list can't — the per-protocol chunking table, E1.31/Art-Net interop notes, the synchronous-send caveat, and the packet-layout headers. +Overview, controls, prior art, source, and tests: [drivers.md § Network Send](drivers.md#networksend). This page carries *only* what no single source file can: the per-protocol chunking table, E1.31/Art-Net interop notes, the synchronous-send caveat, and the packet-layout headers. ![NetworkSendDriver controls](../../../assets/light/drivers/NetworkSendDriver.png) @@ -27,18 +27,6 @@ The whole frame goes out inline in `loop()` — ~35 ms over Ethernet / ~90 ms ov ## Cross-domain wiring -The driver is added as a child of the `Drivers` container at runtime — not boot-wired, but added per board through the catalog (`POST /api/modules`, the board's [`deviceModels.json`](../../../install/deviceModels.json) `modules` entry), so a device only carries the outputs its board actually has. Once added it receives `setSourceBuffer` / `setCorrection` from `Drivers::passBufferToDrivers` (which wires every child generically, boot-added or runtime-added) and applies the shared `const Correction*` before every send — the same correction the RMT LED driver applies, so network and wired outputs show identical colours. The added child persists across reboot via `FilesystemModule`. +The driver is added as a child of the `Drivers` container at runtime — not boot-wired, but added per board through the catalog (`POST /api/modules`, the board's [`deviceModels.json`](../../../../web-installer/deviceModels.json) `modules` entry), so a device only carries the outputs its board actually has. Once added it receives `setSourceBuffer` / `setCorrection` from `Drivers::passBufferToDrivers` (which wires every child generically, boot-added or runtime-added) and applies the shared `const Correction*` before every send — the same correction the RMT LED driver applies, so network and wired outputs show identical colours. The added child persists across reboot via `FilesystemModule`. -## 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) +The live send 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, exercising the wire path no host test reaches. diff --git a/docs/moonmodules/light/drivers/ParlioLedDriver.md b/docs/moonmodules/light/drivers/ParlioLedDriver.md index 77ec1874..c3a77fb6 100644 --- a/docs/moonmodules/light/drivers/ParlioLedDriver.md +++ b/docs/moonmodules/light/drivers/ParlioLedDriver.md @@ -1,10 +1,10 @@ # Parlio LED Driver -Overview and controls: [drivers.md § Parlio LED](drivers.md#parlioled). This page carries the reference detail a control list can't — the P4 pin budget (all three LED peripherals at once), the shared 3-slot wire contract, memory sizing, and cross-domain wiring. +Overview, controls, prior art, source, and tests: [drivers.md § LED output](drivers.md#parlioled). This page carries *only* what no single source file can: the P4 pin budget (all three LED peripherals at once), the shared 3-slot wire contract, memory sizing, and cross-domain wiring. ## Wire contract — 3 slots per bit -Identical to the [LCD driver's](LcdLedDriver.md#wire-contract--3-slots-per-bit): each WS2812 bit becomes three bus slots at 2.67 MHz (slot = 375 ns) — all-active-lanes HIGH, the data bits, then all LOW — so a `1` is HIGH 750 ns and a `0` 375 ns. The 375 ns slot (not the lineage's ~416 ns) keeps T0H inside newer WS2812B revisions' ~380 ns window; the P4 Parlio's 160 MHz PLL clock divides to it exactly (÷60). One 8-bit bus word per slot, bus bit L = the L-th pin of `pins`; short strands idle LOW once exhausted. The ≥300 µs latch is zeroed trailing bytes of the DMA buffer, so the lines rest actively LOW between frames. The encoder is **shared with the LCD driver** — a Parlio bus byte and an i80 bus byte are identical — and lives in [LcdSlots.h](../../../../src/light/drivers/LcdSlots.h). +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. @@ -18,21 +18,4 @@ One internal-RAM DMA frame buffer owned by the platform (PSRAM is deliberately n ## Cross-domain wiring -The driver is added as a child of the `Drivers` container at runtime via the catalog (`POST /api/modules`, a board's [`deviceModels.json`](../../../install/deviceModels.json) `modules` entry) — not boot-wired, so it only exists on a board that selects it. The type is registered on every target, but the peripheral exists only where the SOC has the Parlio TX unit (the P4 among current targets): on a chip without it the driver is inert (`lanesAvailable()` is 0), so a board entry only lists `ParlioLedDriver` where it makes sense. Once added, `Drivers::passBufferToDrivers` wires it like any child. The **slot encode** ([LcdSlots.h](../../../../src/light/drivers/LcdSlots.h), shared) is domain code, host-testable; the **peripheral** (`platform_esp32_parlio.cpp`, ESP-IDF's `esp_driver_parlio` TX unit + DMA) is the only IDF-touching part. - -## 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. -- **`loopbackTxPin` control (CI, host):** the conditional control — bound always, shown only while `loopbackTest` is on. -- **Hardware:** tick-scaling across grid sizes proves frames clock out; the whole-frame loopback self-test bit-verifies the wire signal on the P4. The driver is catalog-added (not boot-wired) — verified on the P4-NANO bench: a fresh-erased board has no `ParlioLed` until a board is selected, a catalog inject creates it under `Drivers` (`pins`=20–27, `ledsPerPin`=64, `loopbackTxPin`=33, `loopbackRxPin`=32), and it persists across a reboot. The loopback drives only lane 0, so `loopbackTxPin` substitutes for lane 0 (TX jumper 33 → RX 32) without retyping `pins`. - -## 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) +The driver is added as a child of the `Drivers` container at runtime via the catalog (`POST /api/modules`, a board's [`deviceModels.json`](../../../../web-installer/deviceModels.json) `modules` entry) — not boot-wired, so it only exists on a board that selects it. The type is registered on every target, but the peripheral exists only where the SOC has the Parlio TX unit (the P4 among current targets): on a chip without it the driver is inert (`lanesAvailable()` is 0), so a board entry only lists `ParlioLedDriver` where it makes sense. Once added, `Drivers::passBufferToDrivers` wires it like any child. The **slot encode** ([LcdSlots.h](../../../../src/light/drivers/LcdSlots.h), shared) is domain code, host-testable; the **peripheral** (`platform_esp32_parlio.cpp`, ESP-IDF's `esp_driver_parlio` TX unit + DMA) is the only IDF-touching part. diff --git a/docs/moonmodules/light/drivers/PreviewDriver.md b/docs/moonmodules/light/drivers/PreviewDriver.md index afdf7052..08969b22 100644 --- a/docs/moonmodules/light/drivers/PreviewDriver.md +++ b/docs/moonmodules/light/drivers/PreviewDriver.md @@ -1,68 +1,31 @@ # Preview Driver -Overview and controls: [drivers.md § Preview](drivers.md#preview). This page carries the reference detail a control list can't — the binary WebSocket protocol (coordinate table + per-frame channels), sparse-layout handling, and the large-layout spatial downsample. - -![PreviewDriver controls](../../../assets/light/drivers/PreviewDriver.png) +Overview, controls, prior art, source, and tests: [drivers.md § Preview](drivers.md#preview). This page carries *only* what no single source file can: the wire contract and the cross-module data flow. (Implementation mechanics live in `PreviewDriver.h`.) ## Protocol -PreviewDriver owns both wire formats end to end and **streams** the bytes to a `BinaryBroadcaster` (the core [HttpServerModule](../../core/HttpServerModule.md)) via `beginBinaryFrame`/`pushBinaryFrame`/`endBinaryFrame` — it never builds a copy of a frame, pushing straight from the producer buffer and the layout's coordinate iterator. The HTTP server only writes the bytes to its WebSocket clients — no knowledge of the preview, the light domain, or the formats below. `main.cpp` wires the driver's broadcaster to the HTTP server instance. This mirrors MoonLight's model: positions sent once at mapping time, channels per frame. - -Two binary message types (first byte selects): - -- **`0x03` coordinate table** — sent on every LUT rebuild (layout add/replace/remove, resize, modifier change), when a new client connects (a generation bump), and when the adaptive downscale factor changes; re-sent on the next tick if a send is dropped under backpressure. Layout: - - `[0x03][count:u32][bx:u8][by:u8][bz:u8][stride:u16][ (x:u8, y:u8, z:u8) × count ]` (10-byte header) - - `count` = points actually sent (**u32** — a HUB75 wall can exceed 65535 lights; matches `nrOfLightsType`); `bx/by/bz` = bounding-box extent (the browser centres the cloud on it); positions are **1 byte per axis** (a layout's bounding box is ≤255/axis in practice; scaled on build if larger). `stride` carries the **downscale factor** (1 = full resolution; >1 = the per-axis lattice step — see Large layouts), which the browser shows as `preview 1/N · link limited`. - -- **`0x02` per-frame channels** — RGB, one triple per sent point, in coordinate-table order: - - `[0x02][count:u32][stride:u16][ (r, g, b) × count ]` (7-byte header) - - The browser colours coordinate-table entry `i` with RGB triple `i`. It **skips a `0x02` frame whose `count` ≠ the current `0x03` count** (a rebuild is mid-flight — the colours would map to the wrong positions); they realign within ~1 frame. The device likewise withholds colour frames until the matching `0x03` has been accepted by the transport, so the two never desync. - -## Sparse layouts & where the data comes from - -The driver reads the **sparse driver buffer** — the `Layer`'s `MappingLUT` extracts the real lights from the dense render grid into a buffer of exactly `Layouts::totalLightCount()` entries (a radius-4 sphere → 210, not its 9×9×9 = 729 box). That same buffer is what ArtNet sends. PreviewDriver reads it flat by light index, and the coordinate table is built in the same driver order (closed-form for a dense grid, via `Layouts::forEachCoord` for a sparse one — see *How the kept lights are chosen*), so RGB index `i` and coordinate `i` always refer to the same light. See [Layer](../Layer.md) / [MappingLUT](../MappingLUT.md) for the box→driver mapping. - -**No preview-side buffers.** Both messages stream straight from the driver buffer — neither holds a copy of a frame: - -- **Colour frame (`0x02`)** at full resolution (`stride`=1, `cpl`=3) is the **driver buffer streamed 1:1** through the resumable `sendBufferedFrame` (header copied, body = the buffer pointer). For a dense identity grid that buffer is the Layer's box buffer; for a sparse/mapped layout (a sphere, a serpentine grid) it's the LUT-mapped output buffer — only the real lights, in driver order, the **same buffer the LED drivers consume**, not the dense box. So the colour count always equals the coordinate-table count. A downsampled (`stride`>1) or non-RGB (`cpl`≠3) frame packs only the kept lights, in the same subset + order the coordinate table used (colour `k` ↔ coord `k`, no stored index map), through the synchronous `begin`/`push`/`end`. Either way: no `rgb_`/gather buffer. -- **Coordinate table (`0x03`)** streams the kept lights' scaled positions — no `coords_` buffer. Sent only on a geometry change / new client / downscale change (rare). - -**How the kept lights are chosen (closed-form vs walk).** A **dense grid in natural box order** (no mapping LUT) is a regular box, so the kept set, the count, each position, and each light's buffer index are all **closed-form from the box dimensions and the stride** — the driver strides the box directly (`for z in 0,s,2s…; y; x`, light `(x,y,z)` at buffer index `z·H·W + y·W + x`), touching only the kept lights. No per-frame `forEachCoord` walk over skipped cells. A **sparse / serpentine / modified** layout has a LUT (arbitrary index↔position map), so those three paths walk `Layouts::forEachCoord` applying the lattice predicate. In practice the sparse case stays under the point cap and sends at `stride`=1 (the 1:1 buffered path, no walk at all), so the per-frame colour walk is effectively never on the hot path. - -**Resumable send + adaptive frame rate (no stall, no buffer).** The full-res colour frame rides [HttpServerModule](../../core/HttpServerModule.md)'s `sendBufferedFrame`, drained a chunk per `loop20ms` from the stable driver buffer — so a large frame (128² = ~49 KB, 196² = ~115 KB) never spins the preview loop. The driver starts a new frame only when `bufferedSendIdle()` (the previous one fully drained), so the **effective frame rate self-limits to what the link sustains**: a fast link hits the `fps` ceiling, a slow link drops to a few fps — and the browser's status line shows the measured rate. A geometry rebuild frees+reallocs the driver buffer, so `onBuildState()` calls `cancelBufferedSend()` first (the browser discards the half-sent message and gets the fresh table + frame next tick) — a use-after-free guard, pinned by a test. - -## Large layouts (spatial downsample + adaptive) - -The preview never freezes and never tears at any grid size: it always delivers a **complete** frame — full-res, at a reduced frame rate, or spatially downsampled — and sheds in that order (rate first, then resolution). The point count is bounded two ways: - -- **Point cap = min(display, memory).** `maxPreviewPoints()` takes the smaller of two bounds. (1) A **display cap** (~4096) — a preview is a browser canvas a few hundred px wide, so beyond a few thousand points the lights are sub-pixel and *more points only cost link bandwidth* (a 16K-point full-res frame streams at <1 fps even on Ethernet). Capping to a display-sensible count makes a big-RAM board downsample to a frame the **link** can push fast — the bottleneck at large grids is throughput, not memory. (2) A **memory cap** derived at runtime from `maxAllocBlock()` with a reserve margin (architecture.md *"sizes determined at runtime based on available memory"*) — it only bites on a board too tight to stream even the display cap, downscaling sooner. Above the resulting cap the driver downsamples on a **spatial lattice** — keep a light only where its grid position lands on a per-axis step `s` (`x,y,z ≡ 0 (mod s)`), which generalises to 2D/3D with no diagonal moiré because it samples *positions*, not flat indices. For a dense grid this is the closed-form `[::s]` stride above; for a sparse layout, the same predicate over `forEachCoord`. Any layout under the cap sends every light (`stride` = 1, exact). -- **Adaptive downscale** — the *deeper* fallback, after frame rate. The struggle signal is **latency**: a buffered frame still draining after a few `fps` slots (the link can't sustain even one frame at this resolution), or a frame/coord table that didn't reach a client. This fires even when frames *eventually* send (the slow-but-complete case a pure all-sent signal misses — e.g. a full-res 196² frame on ethernet that delivers at 2 fps). On sustained struggle the driver coarsens the lattice (`stride`++); a sustained run of prompt, fully-sent frames refines back (hysteresis stops oscillation). The factor rides the `0x03` `stride` field to the browser's status line. - -Positions are 1 byte per axis. A layout whose bounding box exceeds 255 on any axis (e.g. a 512-wide grid) is **scaled** so the largest box edge maps to 255, preserving aspect ratio (the `0x03` header carries the scaled box extents, which the browser normalises against). Boxes ≤255/axis are sent at exact integer positions (scale factor 1), so large grids preview at their true proportions, not flattened onto the 255 plane. - -## Tests +PreviewDriver owns both wire formats and **streams** the bytes to a `BinaryBroadcaster` — the core [HttpServerModule](../../core/moxygen/HttpServerModule.md) — via `beginBinaryFrame`/`pushBinaryFrame`/`endBinaryFrame`, never building a copy of a frame. The HTTP server only writes the bytes to its WebSocket clients: no knowledge of the preview, the light domain, or the formats. `main.cpp` wires the driver's broadcaster to the HTTP server instance. -- [Unit tests: PreviewDriver](../../../tests/unit-tests.md#previewdriver) — coordinate table = real-light count (sphere → 210, not 729), per-frame RGB count matches the table, large layout strides down, small layout exact. -- [Scenario: scenario_Layer_base_pipeline](../../../tests/scenario-tests.md#scenario_layer_base_pipeline) — full pipeline including the preview driver. +The wire layout is the device source itself — edit the header, this follows: -## Prior art +```cpp +--8<-- "src/light/drivers/PreviewDriver.h:wire-format" +``` -### MoonLight — PhysicalLayer + WebSocket ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/PhysicalLayer.h)) +- **`0x03` coordinate table** — sent on geometry change (LUT rebuild), new-client connect, or a downscale-factor change; not per-frame. `stride` carries the downscale factor (1 = full res). +- **`0x02` per-frame channels** — RGB by coordinate-table order. The browser **skips a `0x02` whose `count` ≠ the current `0x03` count** (a rebuild is mid-flight); the device likewise withholds colour frames until the matching `0x03` is accepted, so the two never desync. -The model this implements: virtual(logical grid) → physical(sparse lights) via a mapping table; light **positions sent once** at mapping time (`monitorPass`, `packCoord3DInto3Bytes` = 1 byte/axis, `isPositions` header state), **channels streamed per frame**. 3D WebGL renderer in the frontend. +## Cross-module data flow -### projectMM v1 — PreviewModule ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/drivers/PreviewModule.h)) +PreviewDriver reads the **sparse driver buffer** — the one the LED/ArtNet drivers also consume — which the `Layer`'s [MappingLUT](../moxygen/MappingLUT.md) fills with exactly the real lights (a radius-4 sphere → 210 entries, not its 9×9×9 = 729 box). Both messages stream straight from that buffer and the layout's coordinate iterator; neither holds a preview-side copy. The coordinate table is built in the **same driver order** as the buffer, so RGB index `i` and coordinate `i` always refer to the same light. See [Layer](../moxygen/Layer.md) / [MappingLUT](../moxygen/MappingLUT.md) for the box→driver mapping. -Streamed via WebSocket binary frames. Control: `logEveryN` (slider 1-1000) for throttling. +**Buffer-lifecycle coupling (the non-obvious one).** A large frame rides HttpServerModule's resumable `sendBufferedFrame`, drained a chunk per tick from the *stable* driver buffer — so a 196² frame (~115 KB) never stalls the loop, and the frame rate self-limits to what the link sustains. But a geometry rebuild frees + reallocs that buffer, so `onBuildState()` must call `cancelBufferedSend()` first, or a half-sent frame reads freed memory — a use-after-free guard pinned by a test. This coupling spans PreviewDriver ↔ HttpServerModule ↔ the Layer buffer, and is why the driver streams without its own frame copy. -### projectMM v2 — PreviewModule ([source](https://github.com/ewowi/projectMM-v2/blob/main/src/modules/lights/PreviewModule.h)) +## Large layouts — design rationale -Same pattern, uses v2 DataBuffer for frame data. +The preview never freezes or tears at any grid size: it always delivers a **complete** frame, shedding in order — frame rate first, then spatial resolution. -## Source +- **Point cap = min(display, memory).** A browser canvas is a few hundred px wide, so beyond ~4096 points the lights are sub-pixel and more points only cost link bandwidth (a 16K-point full-res frame streams at <1 fps even on Ethernet) — the bottleneck at large grids is *throughput, not memory*. A second cap from `maxAllocBlock()` only bites on a board too tight to stream even the display cap. Above the cap the driver keeps lights on a **spatial lattice** (position ≡ 0 mod `s`), sampling *positions* not indices so there's no diagonal moiré in 2D/3D. +- **Adaptive downscale** — the deeper fallback after frame rate. The struggle signal is **latency** (a frame still draining after several `fps` slots), catching the slow-but-complete case a pure all-sent signal misses. Sustained struggle coarsens the lattice (`stride`++); sustained prompt sends refine back, with hysteresis to stop oscillation. -[PreviewDriver.h](../../../../src/light/drivers/PreviewDriver.h) +Positions are 1 byte/axis; a box exceeding 255 on any axis is scaled (largest edge → 255, aspect preserved), so large grids preview at true proportions. diff --git a/docs/moonmodules/light/drivers/RmtLedDriver.md b/docs/moonmodules/light/drivers/RmtLedDriver.md index b38642c7..8320f5e3 100644 --- a/docs/moonmodules/light/drivers/RmtLedDriver.md +++ b/docs/moonmodules/light/drivers/RmtLedDriver.md @@ -1,8 +1,8 @@ # RMT LED Driver -Overview and controls: [drivers.md § RMT LED](drivers.md#rmtled). This page carries the reference detail a control list can't — the WS2812B wire contract, buffer slicing, the on-device loopback self-test, and the LED-flicker troubleshooting playbook. +Overview, controls, prior art, source, and tests: [drivers.md § LED output](drivers.md#rmtled). This page carries *only* what no single source file can: the WS2812B wire contract, buffer slicing, the on-device loopback self-test, and the LED-flicker troubleshooting playbook. -Output driver for WS2812B-class addressable LEDs over the ESP32 **[RMT (Remote Control Transceiver)](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/rmt.html)** peripheral — one GPIO and one RMT TX channel per strand. Reads the [Drivers](../Drivers.md) buffer, applies the shared [output correction](../Drivers.md#output-correction) per light, and emits the WS2812 1-wire signal. +Output driver for WS2812B-class addressable LEDs over the ESP32 **[RMT (Remote Control Transceiver)](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/rmt.html)** peripheral — one GPIO and one RMT TX channel per strand. Reads the [Drivers](../moxygen/Drivers.md) buffer, applies the shared [output correction](../archive/Drivers.md#output-correction) per light, and emits the WS2812 1-wire signal. ## Wire contract — [WS2812B](https://cdn-shop.adafruit.com/datasheets/WS2812B.pdf) @@ -25,7 +25,7 @@ The source buffer is split into **consecutive slices**, one per pin, in list ord ## Cross-domain wiring -The driver is added as a child of the `Drivers` container at runtime via the catalog (`POST /api/modules`, a board's [`deviceModels.json`](../../../install/deviceModels.json) `modules` entry) — not boot-wired, exactly like [NetworkSendDriver](NetworkSendDriver.md). RMT is the default LED driver for classic ESP32 and S3 board entries. The type is registered on every target; on a chip without RMT TX channels it is inert. Once added, it receives `setSourceBuffer` / `setCorrection` / `setLayer` from `Drivers::passBufferToDrivers` (which wires every child, boot- or runtime-added), and applies the same `const Correction*` ArtNet uses. The **symbol encode** (`encodeWs2812Symbols` in `RmtSymbol.h`) is domain code in `src/light/` so it is host-testable; the **peripheral** (`platform::rmtWs2812*` in `src/platform/esp32/platform_esp32_rmt.cpp`) is the only ESP-IDF-touching part. Per-chip channel and memory limits come from the IDF SOC capability macros, so the same code serves classic, S3 and P4. +The driver is added as a child of the `Drivers` container at runtime via the catalog (`POST /api/modules`, a board's [`deviceModels.json`](../../../../web-installer/deviceModels.json) `modules` entry) — not boot-wired, exactly like [NetworkSendDriver](NetworkSendDriver.md). RMT is the default LED driver for classic ESP32 and S3 board entries. The type is registered on every target; on a chip without RMT TX channels it is inert. Once added, it receives `setSourceBuffer` / `setCorrection` / `setLayer` from `Drivers::passBufferToDrivers` (which wires every child, boot- or runtime-added), and applies the same `const Correction*` ArtNet uses. The **symbol encode** (`encodeWs2812Symbols` in `RmtSymbol.h`) is domain code in `src/light/` so it is host-testable; the **peripheral** (`platform::rmtWs2812*` in `src/platform/esp32/platform_esp32_rmt.cpp`) is the only ESP-IDF-touching part. Per-chip channel and memory limits come from the IDF SOC capability macros, so the same code serves classic, S3 and P4. The peripheral half uses the [**modern RMT driver**](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/rmt.html) (ESP-IDF 5.x+ "RMT v2": `driver/rmt_tx.h` / `rmt_rx.h` / `rmt_encoder.h` — `rmt_new_tx_channel()`, a copy encoder, `rmt_transmit()`), **not** the legacy channel-numbered API (`driver/rmt.h`, `rmt_config_t`, `RMT_CHANNEL_n`, `rmt_write_items()`). This isn't a preference — the legacy driver was **removed entirely in [ESP-IDF v6](https://docs.espressif.com/projects/esp-idf/en/v6.0/esp32/migration-guides/release-6.x/6.0/peripherals.html)** (the build IDF), so the modern API is the only one that exists. One payoff is portability: the same v2 code serves every RMT-bearing target with no per-chip branching, including the [**P4**](https://www.espressif.com/en/products/socs/esp32-p4), whose RMT additionally has a DMA backend (`SOC_RMT_SUPPORT_DMA`, used by the whole-frame loopback capture — the classic ESP32 has no RMT DMA). @@ -50,21 +50,3 @@ When 1–3 all come back clean, the fix is electrical, in rough order of effecti - **Shorten / shield the data wire**, and keep it away from the power leads and the antenna. - **Share a solid, thick common ground** between the strip's supply and the board. - If RF coupling was implicated by step 3, set a per-board `Network.txPowerSetting` cap (the same `deviceModels.json` mechanism the ESP32-S3 N16R8 Dev uses). - -## 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). -- **`loopbackTxPin` control (CI, host):** the conditional control — bound always (so persistence can load it), shown only while `loopbackTest` is on. -- **Hardware:** the driver is catalog-added (not boot-wired) — verified on the S3 and classic ESP32 bench: a fresh-erased board has no `RmtLed` until a board is selected, a catalog inject creates it under `Drivers` with its pins, and it persists across a reboot. The `loopbackTxPin` override is set distinct from the operational `pins` (S3: `pins`=18, `loopbackTxPin`=13; classic: `pins`=18, `loopbackTxPin`=4), so flipping `loopbackTest` runs the self-test on the jumper pin without retyping `pins`. - -## 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/drivers/drivers.md b/docs/moonmodules/light/drivers/drivers.md index c7c78141..19df7cb8 100644 --- a/docs/moonmodules/light/drivers/drivers.md +++ b/docs/moonmodules/light/drivers/drivers.md @@ -1,111 +1,92 @@ # Drivers -Every driver, one block each: what it does and what each control means — together. A driver reads its window of the [Drivers](../Drivers.md) container's shared buffer, applies the shared [output correction](../Drivers.md#output-correction) (brightness / channel order / RGBW white) per light, and sends the result out — over a wire protocol (WS2812 on RMT / LCD_CAM / Parlio), over the network (Art-Net / E1.31 / DDP), to a smart-light hub (Hue), or to the web UI (Preview). A driver is added as a child of the `Drivers` container per board through the catalog (`POST /api/modules`, a board's [`deviceModels.json`](../../../install/deviceModels.json) `modules` entry), so a device only carries the outputs its board actually has; `PreviewDriver` is the one boot-wired driver. - -Each block links to that driver's **detail page** for the deeper material a control list can't carry — wire contracts, buffer slicing, memory sizing, per-protocol chunking, the on-device loopback self-test, and troubleshooting. Every driver shares the `start` / `count` **source-window** controls ([Drivers § Per-driver source window](../Drivers.md#per-driver-source-window-start--count)): the slice `[start, start+count)` of the buffer this driver sends (`count` 0 = to the end), so different drivers can cover different ranges of one buffer. +A driver reads its window of the [Drivers](../moxygen/Drivers.md) container's shared buffer, applies the shared [output correction](../archive/Drivers.md#output-correction) per light, and sends the result out — a wire protocol (WS2812), the network (Art-Net / E1.31 / DDP), a smart-light hub (Hue), or the web UI (Preview). Drivers are added per board through the catalog ([`deviceModels.json`](../../../../web-installer/deviceModels.json)); `PreviewDriver` is the one boot-wired driver. Every driver shares the `start` / `count` [source-window](../archive/Drivers.md#per-driver-source-window-start-count) controls (the slice `[start, start+count)` it sends). Each card links to a detail page and, where it doesn't fit the table, a **⌄ details** section below. **Jump to:** [LED output](#led-output-drivers) · [Network](#network-drivers) · [Smart light](#smart-light-drivers) · [Preview](#preview-drivers) ## LED output drivers - -### RMT LED - -WS2812B-class addressable LEDs over the ESP32 **RMT** peripheral — one GPIO and one RMT TX channel per strand. The general single-/few-strand LED output; the default LED driver for classic ESP32 and S3 board entries. Runs on any chip whose RMT has TX channels (classic ESP32: 8, S3: 4, P4: 4 DMA-backed); inert on desktop. - -- `pins` — comma-separated data / TX GPIO list, e.g. `18,17,16` (one RMT TX channel per pin). Empty by default (idles until set). Changing it re-inits the channels live, no reboot. -- `ledsPerPin` — comma-separated lights-per-pin, matched to `pins` by position; empty or shorter than `pins` splits the remainder evenly. -- `loopbackTest` — persistent on/off mode for the RMT TX→RX loopback self-test (jumper the first `pins` entry to `loopbackRxPin`); verdict lands in the status field. -- `loopbackFrame` — whole-frame variant of the self-test (transmits a real frame back-to-back, bit-verifies the whole capture — catches frame-rate / RF corruption a 24-bit burst misses). Shown only while `loopbackTest` is on. -- `loopbackTxPin` — optional TX override for the self-test (transmit on this pin instead of `pins[0]`). Shown only while `loopbackTest` is on. -- `loopbackRxPin` — the RX pin for the self-test. Shown only while `loopbackTest` is on. - -Detail: [RmtLedDriver.md](RmtLedDriver.md) — WS2812B wire contract, buffer slicing, concurrent show, the loopback self-test, and the LED-flicker troubleshooting playbook. - + -### LCD LED - -Parallel WS2812B on the **ESP32-S3** over the **LCD_CAM** 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 RMT tops out at 4 channels. - -- `pins` — comma-separated data GPIOs, one lane each, **exactly 8** (the i80 peripheral configures every data line of the bus width). Empty by default (idles until set). Changing it re-creates the i80 bus live. -- `ledsPerPin` — lights per lane, matched by position; empty = even split. Give unused lanes `0` to drive fewer than 8 strands. -- `clockPin` (default 10) — the i80 bus WR line; required on a real GPIO by the peripheral, ignored by the LEDs, overridable. -- `dcPin` (default 11) — the i80 data/command line; same story — required, unused by the LEDs, overridable. -- `loopbackTest` — one-shot whole-frame signal self-test (jumper the first pin to `loopbackRxPin`). Result in the status field. -- `loopbackTxPin` — optional TX override (drives lane 0 with the test pattern on this pin instead of `pins[0]`). Shown only while `loopbackTest` is on. -- `loopbackRxPin` — the RX pin for the self-test. Shown only while `loopbackTest` is on. - -Detail: [LcdLedDriver.md](LcdLedDriver.md) — 3-slot-per-bit wire contract, buffer slicing, DMA memory sizing, and cross-domain wiring. +### LED output 💫 · wire - +Addressable WS2812B-class LEDs over a wire, one GPIO per strand. Three peripherals do this — pick by chip: **RMT** (single/few strands, any ESP32), **LCD_CAM** (8 parallel strands, S3), **Parlio** (1–8 parallel strands, P4). Same controls, same wire contract; they differ only in how many strands clock out at once and on which chip. -### Parlio LED +- `pins` — data GPIO list, e.g. `18,17,16` (one strand each). Empty idles until set; changing it re-inits live. +- `ledsPerPin` — lights per pin, matched by position; empty or short = even split of the remainder. +- `loopbackTest` — on/off TX→RX loopback self-test (jumper the first pin to `loopbackRxPin`); verdict in the status field. +- `loopbackTxPin` / `loopbackRxPin` — optional TX override + the RX pin for the self-test. Shown only while `loopbackTest` is on. -Parallel WS2812B on the **ESP32-P4** over the **Parlio (Parallel IO)** TX peripheral: up to **8 strands** clock out simultaneously via one autonomous DMA transfer — the P4's preferred parallel path (the sibling of the LCD driver on the S3). Runs on **1–8 lanes** (no exactly-8 rule), with no clock/dc pins (Parlio generates its own pixel clock). +Origin: WS2812B on FastLED / hpwit / WLED prior art ([analysis](../../../backlog/leddriver-analysis-top-down.md)) -- `pins` — comma-separated data GPIOs, one lane each, **1 to 8**. Empty by default (idles until set). On the P4-NANO avoid the strapping (34–38), Ethernet, C6-SDIO and I2C pins; a known-good set is `20,21,22,23,24,25,26,27`. Changing it re-creates the Parlio TX unit live. -- `ledsPerPin` — lights per lane, matched by position; empty = even split over the wired lanes. -- `loopbackTest` — one-shot whole-frame signal self-test (TX on the first pin, RX on `loopbackRxPin`). Verdict in the status field. -- `loopbackTxPin` — optional TX override (lane 0's test pattern goes to this pin instead of `pins[0]`). Shown only while `loopbackTest` is on. -- `loopbackRxPin` — the RX pin for the self-test. Shown only while `loopbackTest` is on. +[Tests](../../../tests/unit-tests.md#rmtleddriver) -Detail: [ParlioLedDriver.md](ParlioLedDriver.md) — the P4 pin budget (all three LED peripherals at once, up to 20 parallel strands), the shared 3-slot wire contract, memory sizing, and cross-domain wiring. +Detail: RMT [.h](../../../../src/light/drivers/RmtLedDriver.h) · [md](RmtLedDriver.md) — LCD [.h](../../../../src/light/drivers/LcdLedDriver.h) · [md](LcdLedDriver.md) — Parlio [.h](../../../../src/light/drivers/ParlioLedDriver.h) · [md](ParlioLedDriver.md) ## Network drivers -### Network Send +### Network Send 💫 · UDP + +NetworkSend controls -![NetworkSend controls](../../../assets/light/drivers/NetworkSendDriver.png) +Streams the buffer over UDP as **Art-Net**, **E1.31 / sACN**, or **DDP** — one burst per frame, compatible with Falcon/Advatek controllers, xLights, and LedFx. -Streams the light buffer over UDP in one of three industry protocols — **Art-Net**, **E1.31 / sACN**, or **DDP** — selected by a control. Sends the whole frame as one burst per configured rate; compatible with pixel controllers (Falcon, Advatek), xLights, and LedFx. +- `protocol` — Art-Net / E1.31 / DDP (default Art-Net); the destination port follows automatically. +- `ip` — destination (default `255.255.255.255` broadcast reaches every LAN receiver; set a unicast address to target one). +- `universe_start` — first universe for Art-Net / E1.31 (DDP is byte-addressed, ignores it). +- `fps` — frame-rate limit (default 50, 1–120). -- `protocol` (Art-Net / E1.31 / DDP, default Art-Net) — the wire protocol; the destination port follows automatically (6454 / 5568 / 4048). Changing it re-targets the socket live. -- `ip` (default 255.255.255.255) — destination address; the default limited-broadcast reaches every LAN receiver with no IP to type. Set a unicast address to target one device. (E1.31 multicast is deliberately not implemented — see the detail page.) -- `universe_start` (default 0) — first universe for Art-Net and E1.31; DDP is byte-addressed and ignores it. -- `fps` (default 50, range 1–120) — frame-rate limit (without it the loop would re-send every render tick; receivers expect a steady cadence). +Origin: MoonLight D_NetworkOut; Art-Net 4 / E1.31 / DDP specs -Detail: [NetworkSendDriver.md](NetworkSendDriver.md) — per-protocol chunking table, E1.31/Art-Net interop notes, the synchronous-send caveat, and the packet-layout headers. +[Tests](../../../tests/unit-tests.md#networksenddriver) · [.h](../../../../src/light/drivers/NetworkSendDriver.h) · [detail](NetworkSendDriver.md) ## Smart light drivers -### Hue +### Hue 💫 · bridge -![A HueDriver in the UI](../../../assets/light/drivers/Hue%20driver.png) +A HueDriver in the UI -Drives **Philips Hue bulbs as pixels of an effect**: make a small grid, run any effect, add a HueDriver, and each colour bulb in the driver's window becomes one pixel, pushed to the bridge over the Hue HTTP API. Paces itself to the bridge's ~10 command/s rate limit — smooth ambient colour, not fast strobing. Only colour-capable, reachable bulbs are driven. +Drives **Philips Hue bulbs as pixels**: each colour bulb in the driver's window becomes one pixel, pushed to the bridge over its HTTP API. Paced to the bridge's ~10 cmd/s limit — smooth ambient colour, not strobing. -- `bridgeIp` — the Hue bridge's LAN IPv4 (find it via the bridge app, the router, or `discovery.meethue.com`). -- `appKey` — the Hue app key (username); filled automatically by `pair`, persisted as the driver's credential. -- `pair` — a button: press it, then press the bridge's physical link button within ~30 s; the driver claims a key and learns the light list. -- `room` / `light` — two dropdowns narrowing which colour lights are driven (both default to `All`); pick a room, then optionally one bulb. +- `bridgeIp` — the bridge's LAN IPv4. +- `appKey` — the Hue app key; filled by `pair`, persisted. +- `pair` — button: press it, then the bridge's physical link button within ~30 s to claim a key. +- `room` / `light` — dropdowns narrowing which colour lights are driven (both default `All`). -Detail: [HueDriver.md](HueDriver.md) — what makes Hue different (rate limit, bridge-smoothed transitions, pairing), the Hue v1 HTTP wire contract, and the Devices-module listing. +Origin: projectMM, on the [Hue v1 CLIP API](https://developers.meethue.com/develop/hue-api/) + +[Tests](../../../tests/unit-tests.md#huedriver) · [.h](../../../../src/light/drivers/HueDriver.h) · [detail](HueDriver.md) ## Preview drivers -### Preview +### Preview 💫 · web UI + +PreviewDriver controls + +Streams a true-shape 3D preview to the web UI over WebSocket as a **point list** — only the real lights at their real positions, so a sphere/ring/arbitrary map shows in its true shape. The one boot-wired driver. + +- `fps` — preview stream rate (default 24, 1–60; independent of the render loop). -![PreviewDriver controls](../../../assets/light/drivers/PreviewDriver.png) +Origin: projectMM, on [MoonLight](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/PhysicalLayer.h)'s PhysicalLayer model -Streams a true-shape 3D preview to the web UI over WebSocket as a **point list** — only the real lights at their real positions, not a dense grid — so a sphere, ring, or arbitrary fixture map shows in its true shape. The one boot-wired driver. +[Tests](../../../tests/unit-tests.md#previewdriver) · [.h](../../../../src/light/drivers/PreviewDriver.h) · [detail](PreviewDriver.md) -- `fps` (default 24, range 1–60) — preview stream rate (independent of the render loop). +## LED output — details -Detail: [PreviewDriver.md](PreviewDriver.md) — the binary WebSocket protocol (coordinate table + per-frame channels), sparse-layout handling, and the large-layout spatial downsample. +The three LED-output peripherals, compared. All drive WS2812B-class strips with the same `pins` / `ledsPerPin` / `loopback*` controls and the same wire contract; they differ in parallelism and chip. -## Source +| Peripheral | Chip | Strands | Notes | +|------------|------|---------|-------| +| **RMT** ([RmtLedDriver.md](RmtLedDriver.md)) | any ESP32 (classic 8 ch, S3 4, P4 4 DMA) | one per RMT TX channel | the general single-/few-strand output; default for classic + S3 board entries. Adds `loopbackFrame` — a whole-frame variant of the self-test (bit-verifies a full frame, catching frame-rate / RF corruption a 24-bit burst misses). | +| **LCD_CAM** ([LcdLedDriver.md](LcdLedDriver.md)) | ESP32-S3 | **exactly 8** parallel (one DMA transfer) | the S3's scale path where RMT tops out at 4. Adds `clockPin` (10) / `dcPin` (11) — i80 bus lines the LEDs ignore. Give unused lanes `0`. | +| **Parlio** ([ParlioLedDriver.md](ParlioLedDriver.md)) | ESP32-P4 | **1–8** parallel (one DMA transfer) | the P4's parallel path (Parlio generates its own pixel clock — no clock/dc pins). On P4-NANO a known-good set is `20,21,22,23,24,25,26,27`. | -- [RmtLedDriver.h](../../../../src/light/drivers/RmtLedDriver.h) -- [LcdLedDriver.h](../../../../src/light/drivers/LcdLedDriver.h) -- [ParlioLedDriver.h](../../../../src/light/drivers/ParlioLedDriver.h) -- [NetworkSendDriver.h](../../../../src/light/drivers/NetworkSendDriver.h) -- [HueDriver.h](../../../../src/light/drivers/HueDriver.h) -- [PreviewDriver.h](../../../../src/light/drivers/PreviewDriver.h) +The detail pages carry each peripheral's wire contract, buffer slicing, memory sizing, and the loopback self-test. diff --git a/docs/moonmodules/light/effects/effects.md b/docs/moonmodules/light/effects/effects.md index 94b99952..d41f7458 100644 --- a/docs/moonmodules/light/effects/effects.md +++ b/docs/moonmodules/light/effects/effects.md @@ -1,6 +1,6 @@ # Effects -Every effect, one block each: its preview, what it does, and what each control means — together. An effect writes per-pixel colour into its [Layer](../Layer.md)'s buffer each tick; [modifiers](../modifiers/modifiers.md) reshape the result and a [driver](../drivers/PreviewDriver.md) sends it out. Effects that name an index colour read the global palette (the `palette` control on [Drivers](../Drivers.md)) via `colorFromPalette`. Each block's emoji are its `tags()` (origin/creator/audio — see the [tag emoji legend](../../../architecture.md#tag-emoji-legend)); **Dim** is its native axes ([Layer](../Layer.md) extrudes a lower-dim effect onto a bigger grid). Effects are grouped into sections by origin, and each block carries that effect's preview, behaviour, and control descriptions together. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../../backlog/folder-structure-proposal.md).) +Every effect, one block each: its preview, what it does, and what each control means — together. An effect writes per-pixel colour into its [Layer](../moxygen/Layer.md)'s buffer each tick; [modifiers](../modifiers/modifiers.md) reshape the result and a [driver](../drivers/PreviewDriver.md) sends it out. Effects that name an index colour read the global palette (the `palette` control on [Drivers](../moxygen/Drivers.md)) via `colorFromPalette`. Each block's emoji are its `tags()` (origin/creator/audio — see the [tag emoji legend](../../../architecture.md#tag-emoji-legend)); **Dim** is its native axes ([Layer](../moxygen/Layer.md) extrudes a lower-dim effect onto a bigger grid). Effects are grouped into sections by origin, and each block carries that effect's preview, behaviour, and control descriptions together. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../../backlog/folder-structure-proposal.md).) **Jump to:** [MoonLight](#moonlight-effects) · [MoonModules](#moonmodules-effects) · [WLED](#wled-effects) · [FastLED](#fastled-effects) · [projectMM-native](#projectmm-native-effects) @@ -180,7 +180,7 @@ Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/bl Expanding concentric rings from random centres, additive overlap (calm defaults). -- `count` — simultaneous rings (1–8 active). +- `count` — number of concentric rings (1–255). - `speed` — expansion rate. - `thickness` — ring band width. - `hue_shift` — rotate every ring's hue. @@ -572,7 +572,7 @@ Origin: projectMM original (VU meter) · source [AudioVolumeEffect.h](../../../. ### DemoReel 🎬 · 3D -A demo reel: plays every other registered effect in turn, auto-advancing on a timer, so one Layer cycles the whole library hands-free — the showcase/test tool for everything. It hosts a single live effect at a time (created from the effect registry, rendered into this Layer) and swaps to the next when the interval elapses — new effects are picked up automatically. It can also pick a fresh palette each cycle and overlay the playing effect's name. The `status` line shows which effect is playing (e.g. `playing: Plasma (3/20)`). It never hosts itself, and it plays effects in sequence rather than compositing them (layering is the [Layer](../Layer.md) stack's job). +A demo reel: plays every other registered effect in turn, auto-advancing on a timer, so one Layer cycles the whole library hands-free — the showcase/test tool for everything. It hosts a single live effect at a time (created from the effect registry, rendered into this Layer) and swaps to the next when the interval elapses — new effects are picked up automatically. It can also pick a fresh palette each cycle and overlay the playing effect's name. The `status` line shows which effect is playing (e.g. `playing: Plasma (3/20)`). It never hosts itself, and it plays effects in sequence rather than compositing them (layering is the [Layer](../moxygen/Layer.md) stack's job). - `interval` — seconds each effect plays before advancing (1–120). - `shuffle` — jump to a random next effect instead of registry order. @@ -612,44 +612,3 @@ Origin: MoonLight (Sinus, AI-generated) · via [MoonLight](https://github.com/Mo [Tests](../../../tests/unit-tests.md#sineeffect) -## Source - -- [AudioSpectrumEffect.h](../../../../src/light/effects/AudioSpectrumEffect.h) -- [AudioVolumeEffect.h](../../../../src/light/effects/AudioVolumeEffect.h) -- [BlurzEffect.h](../../../../src/light/effects/BlurzEffect.h) -- [BouncingBallsEffect.h](../../../../src/light/effects/BouncingBallsEffect.h) -- [DemoReelEffect.h](../../../../src/light/effects/DemoReelEffect.h) -- [DistortionWavesEffect.h](../../../../src/light/effects/DistortionWavesEffect.h) -- [FireEffect.h](../../../../src/light/effects/FireEffect.h) -- [FixedRectangleEffect.h](../../../../src/light/effects/FixedRectangleEffect.h) -- [FreqMatrixEffect.h](../../../../src/light/effects/FreqMatrixEffect.h) -- [FreqSawsEffect.h](../../../../src/light/effects/FreqSawsEffect.h) -- [GEQ3DEffect.h](../../../../src/light/effects/GEQ3DEffect.h) -- [GEQEffect.h](../../../../src/light/effects/GEQEffect.h) -- [GameOfLifeEffect.h](../../../../src/light/effects/GameOfLifeEffect.h) -- [LavaLampEffect.h](../../../../src/light/effects/LavaLampEffect.h) -- [LinesEffect.h](../../../../src/light/effects/LinesEffect.h) -- [LissajousEffect.h](../../../../src/light/effects/LissajousEffect.h) -- [MetaballsEffect.h](../../../../src/light/effects/MetaballsEffect.h) -- [NetworkReceiveEffect.h](../../../../src/light/effects/NetworkReceiveEffect.h) -- [Noise2DEffect.h](../../../../src/light/effects/Noise2DEffect.h) -- [NoiseEffect.h](../../../../src/light/effects/NoiseEffect.h) -- [NoiseMeterEffect.h](../../../../src/light/effects/NoiseMeterEffect.h) -- [PaintBrushEffect.h](../../../../src/light/effects/PaintBrushEffect.h) -- [ParticlesEffect.h](../../../../src/light/effects/ParticlesEffect.h) -- [PlasmaEffect.h](../../../../src/light/effects/PlasmaEffect.h) -- [PraxisEffect.h](../../../../src/light/effects/PraxisEffect.h) -- [RainbowEffect.h](../../../../src/light/effects/RainbowEffect.h) -- [RandomEffect.h](../../../../src/light/effects/RandomEffect.h) -- [RingsEffect.h](../../../../src/light/effects/RingsEffect.h) -- [RipplesEffect.h](../../../../src/light/effects/RipplesEffect.h) -- [RubiksCubeEffect.h](../../../../src/light/effects/RubiksCubeEffect.h) -- [SineEffect.h](../../../../src/light/effects/SineEffect.h) -- [SolidEffect.h](../../../../src/light/effects/SolidEffect.h) -- [SphereMoveEffect.h](../../../../src/light/effects/SphereMoveEffect.h) -- [SpiralEffect.h](../../../../src/light/effects/SpiralEffect.h) -- [StarFieldEffect.h](../../../../src/light/effects/StarFieldEffect.h) -- [StarSkyEffect.h](../../../../src/light/effects/StarSkyEffect.h) -- [TetrixEffect.h](../../../../src/light/effects/TetrixEffect.h) -- [TextEffect.h](../../../../src/light/effects/TextEffect.h) -- [WaveEffect.h](../../../../src/light/effects/WaveEffect.h) diff --git a/docs/moonmodules/light/layouts/layouts.md b/docs/moonmodules/light/layouts/layouts.md index 387e477d..aaccffcf 100644 --- a/docs/moonmodules/light/layouts/layouts.md +++ b/docs/moonmodules/light/layouts/layouts.md @@ -1,6 +1,6 @@ # Layouts -Every layout, one block each: what it does and what each control means — together. A layout maps light indices to physical `(x, y, z)` positions — it defines the *shape* an [effect](../effects/effects.md) draws onto and a [driver](../drivers/) sends out. The [Layouts](../Layouts.md) container holds one or more layout children and composes them into one coordinate space; a [Layer](../Layer.md) renders over that combined space. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../../backlog/folder-structure-proposal.md).) +Every layout, one block each: what it does and what each control means — together. A layout maps light indices to physical `(x, y, z)` positions — it defines the *shape* an [effect](../effects/effects.md) draws onto and a [driver](../drivers/) sends out. The [Layouts](../moxygen/Layouts.md) container holds one or more layout children and composes them into one coordinate space; a [Layer](../moxygen/Layer.md) renders over that combined space. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../../backlog/folder-structure-proposal.md).) ## MoonLight layouts @@ -193,22 +193,5 @@ Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/bl [Tests](../../../tests/unit-tests.md#wheellayout) -The [Layouts](../Layouts.md) container itself takes no controls — see its page for coordinate iteration, reordering, and rebuild propagation. - -## Source - -- [CarLightsLayout.h](../../../../src/light/layouts/CarLightsLayout.h) -- [CubeLayout.h](../../../../src/light/layouts/CubeLayout.h) -- [GridLayout.h](../../../../src/light/layouts/GridLayout.h) -- [HumanSizedCubeLayout.h](../../../../src/light/layouts/HumanSizedCubeLayout.h) -- [PanelLayout.h](../../../../src/light/layouts/PanelLayout.h) -- [PanelsLayout.h](../../../../src/light/layouts/PanelsLayout.h) -- [RingLayout.h](../../../../src/light/layouts/RingLayout.h) -- [Rings241Layout.h](../../../../src/light/layouts/Rings241Layout.h) -- [SingleColumnLayout.h](../../../../src/light/layouts/SingleColumnLayout.h) -- [SingleRowLayout.h](../../../../src/light/layouts/SingleRowLayout.h) -- [SphereLayout.h](../../../../src/light/layouts/SphereLayout.h) -- [SpiralLayout.h](../../../../src/light/layouts/SpiralLayout.h) -- [TorontoBarGourdsLayout.h](../../../../src/light/layouts/TorontoBarGourdsLayout.h) -- [TubesLayout.h](../../../../src/light/layouts/TubesLayout.h) -- [WheelLayout.h](../../../../src/light/layouts/WheelLayout.h) +The [Layouts](../moxygen/Layouts.md) container itself takes no controls — see its page for coordinate iteration, reordering, and rebuild propagation. + diff --git a/docs/moonmodules/light/modifiers/modifiers.md b/docs/moonmodules/light/modifiers/modifiers.md index 961eb0ff..5de50e1f 100644 --- a/docs/moonmodules/light/modifiers/modifiers.md +++ b/docs/moonmodules/light/modifiers/modifiers.md @@ -1,6 +1,6 @@ # Modifiers -Every modifier, one block each: its preview, what it does, and what each control means — together. A modifier sits between an [effect](../effects/effects.md) and the output: it reshapes *where* pixels land (or masks them) without changing the effect's drawing. Modifiers compose — a [Layer](../Layer.md) folds its whole modifier stack each rebuild; a *dynamic* modifier (one that overrides `modifyLive`) also runs a per-frame pass. See [ModifierBase](../ModifierBase.md) for the static-vs-dynamic split. Each block's emoji are its `tags()` (see the [tag emoji legend](../../../architecture.md#tag-emoji-legend)); **Kind** is static (baked into the mapping at rebuild) or dynamic (per-frame remap). Modifiers are grouped into sections, and each block carries that modifier's preview, behaviour, and control descriptions together. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../../backlog/folder-structure-proposal.md).) +Every modifier, one block each: its preview, what it does, and what each control means — together. A modifier sits between an [effect](../effects/effects.md) and the output: it reshapes *where* pixels land (or masks them) without changing the effect's drawing. Modifiers compose — a [Layer](../moxygen/Layer.md) folds its whole modifier stack each rebuild; a *dynamic* modifier (one that overrides `modifyLive`) also runs a per-frame pass. See [ModifierBase](../moxygen/ModifierBase.md) for the static-vs-dynamic split. Each block's emoji are its `tags()` (see the [tag emoji legend](../../../architecture.md#tag-emoji-legend)); **Kind** is static (baked into the mapping at rebuild) or dynamic (per-frame remap). Modifiers are grouped into sections, and each block carries that modifier's preview, behaviour, and control descriptions together. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../../backlog/folder-structure-proposal.md).) ## MoonLight modifiers @@ -146,16 +146,3 @@ Origin: MoonLight · by WildCats08 / [@Brandon502](https://github.com/Brandon502 [Tests](../../../tests/unit-tests.md#rotatemodifier) -## Source - -- [BlockModifier.h](../../../../src/light/modifiers/BlockModifier.h) -- [CheckerboardModifier.h](../../../../src/light/modifiers/CheckerboardModifier.h) -- [CircleModifier.h](../../../../src/light/modifiers/CircleModifier.h) -- [MirrorModifier.h](../../../../src/light/modifiers/MirrorModifier.h) -- [MultiplyModifier.h](../../../../src/light/modifiers/MultiplyModifier.h) -- [PinwheelModifier.h](../../../../src/light/modifiers/PinwheelModifier.h) -- [RandomMapModifier.h](../../../../src/light/modifiers/RandomMapModifier.h) -- [RegionModifier.h](../../../../src/light/modifiers/RegionModifier.h) -- [RippleXZModifier.h](../../../../src/light/modifiers/RippleXZModifier.h) -- [RotateModifier.h](../../../../src/light/modifiers/RotateModifier.h) -- [TransposeModifier.h](../../../../src/light/modifiers/TransposeModifier.h) diff --git a/docs/moonmodules/light/supporting/supporting.md b/docs/moonmodules/light/supporting/supporting.md new file mode 100644 index 00000000..8cae5501 --- /dev/null +++ b/docs/moonmodules/light/supporting/supporting.md @@ -0,0 +1,68 @@ +# Light supporting modules + +The light-domain machinery the catalog modules (effects, modifiers, layouts, drivers) lean on — not directly user-facing. Every row links to its generated technical page (the full API, from the `.h`) and its tests. Cross-cutting rationale that no single `.h` owns lives in the prose sections below the table. + +### Layer + +One rendering layer — an effect writes into its buffer, modifiers transform the coordinate mapping, and the layer composites onto the shared output. The unit the render loop iterates. + +- `blendMode` — how this layer composites onto the ones below (overwrite / alpha / additive). + +Detail: [technical](../moxygen/Layer.md) + +[Tests](../../../tests/unit-tests.md#layer) + +### Layers + +The container of layers — composites them (blend mode + opacity per layer) into the final light buffer. + +Detail: [technical](../moxygen/Layers.md) + +[Tests](../../../tests/unit-tests.md#layers) + +### Layouts + +The container of layout modules — walks each layout's coordinates to build the physical light set the mapping consumes. + +Detail: [technical](../moxygen/Layouts.md) + +[Tests](../../../tests/unit-tests.md#layouts) + +### Drivers + +The container of driver modules — owns the shared driver buffer and the per-light output correction every driver applies before sending. + +- `lightPreset` — the output-correction preset (colour order, gamma, brightness) every driver applies. +- `palette` — the active palette effects sample from. + +Detail: [technical](../moxygen/Drivers.md) + +[Tests](../../../tests/unit-tests.md#drivers) + +### Buffer + +Contiguous light-data buffer, shared between the layers that write it (effects) and the driver groups that read it. A raw `uint8_t*` so any channel layout fits — RGB, RGBW, multi-channel DMX. + +Detail: [technical](../moxygen/Buffer.md) + +[Tests](../../../tests/unit-tests.md#buffer) + +### MappingLUT + +Maps the virtual grid to the physical sparse light set — a radius-4 sphere becomes its 210 real lights, not the 729-cell box. The lookup effects and the preview both consume. + +Detail: [technical](../moxygen/MappingLUT.md) + +[Tests](../../../tests/unit-tests.md#mappinglut) + +### Effect base + +The `EffectBase` class every effect derives from — the shared surface (buffer access, dimensions, the palette) an effect renders against. + +Detail: [technical](../moxygen/EffectBase.md) + +### Modifier base + +The `ModifierBase` class every modifier derives from — transforms the coordinate mapping (mirror, rotate, multiply, …) a layer applies before rendering. + +Detail: [technical](../moxygen/ModifierBase.md) diff --git a/docs/performance.md b/docs/performance.md index ed9f079a..3d53fba4 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -4,7 +4,7 @@ projectMM's per-step **performance contracts** live in the scenario JSONs — ea This document holds what scenarios can't carry: structural sizes (`sizeof`), build-variant deltas, and the WiFi/Ethernet physics that explain *why* a contract comes out where it does. -**Render-loop model.** The Layer's buffer **persists** frame-to-frame — `Layer::loop()` does not clear it (the FastLED/WLED/MoonLight convention; see [architecture.md § Buffer persistence](architecture.md#buffer-persistence--the-layer-does-not-clear-each-frame)). This removed the per-frame full-buffer `memset` that a clear-every-frame model pays, and replaced N per-effect `draw::fade` passes with a single **collected fade** (`Layer::fadeToBlackBy` MINs the requested amounts and applies one buffer pass per frame) — so a layer with several fading effects now pays one fade pass, not N. Net hot-path effect on the tick numbers below is small (the clear/fade are one linear pass over the buffer, dwarfed by per-light effect compute and the output driver), but the *model* is what the scenario `observed` blocks were re-measured against on this cycle. +**Render-loop model.** The Layer's buffer **persists** frame-to-frame — `Layer::loop()` does not clear it (the FastLED/WLED/MoonLight convention; see [architecture.md § Buffer persistence](architecture.md#buffer-persistence-the-layer-does-not-clear-each-frame)). This removed the per-frame full-buffer `memset` that a clear-every-frame model pays, and replaced N per-effect `draw::fade` passes with a single **collected fade** (`Layer::fadeToBlackBy` MINs the requested amounts and applies one buffer pass per frame) — so a layer with several fading effects now pays one fade pass, not N. Net hot-path effect on the tick numbers below is small (the clear/fade are one linear pass over the buffer, dwarfed by per-light effect compute and the output driver), but the *model* is what the scenario `observed` blocks were re-measured against on this cycle. --- diff --git a/docs/reference/esp32-s31-coreboard.md b/docs/reference/esp32-s31-coreboard.md index 49d37dbe..9ad8a93e 100644 --- a/docs/reference/esp32-s31-coreboard.md +++ b/docs/reference/esp32-s31-coreboard.md @@ -31,7 +31,7 @@ The onboard electret mic (J6) and speaker connect through an **ES8311 mono codec | PA_CTRL | 57 | NS4150B amplifier enable | > **SDA/SCL are GPIO51/GPIO50** — the *opposite* of what the schematic's `ESP_I2C_SDA` / -> `ESP_I2C_SCL` net labels suggest. Bench-confirmed: the [I2cScanModule](../moonmodules/core/I2cScanModule.md) +> `ESP_I2C_SCL` net labels suggest. Bench-confirmed: the [I2cScanModule](../moonmodules/core/moxygen/I2cScanModule.md) > (sda=51, scl=50 in the S31 catalog entry) finds the ES8311 ACK at 0x18; with 50/51 nothing > ACKs. The other audio pins match the schematic + the chip's GPIO table (all of GPIO50–57 are > plain I/O GPIOs routed through the matrix — no special-function conflict). @@ -84,10 +84,10 @@ Wi-Fi 6 · Bluetooth (no separate BLE soc-flag) · IEEE 802.15.4 (Thread/Zigbee) TWAI (CAN) · RMT · Parlio · LCD_CAM i80 · on-chip EMAC · PSRAM. RISC-V dual-core. The S31 catalog entry drives **LEDs** (RMT on GPIO60) and **Wi-Fi 6**, and wires an -**[I2cScanModule](../moonmodules/core/I2cScanModule.md)** on the codec bus (SDA 51 / SCL 50) for -I2C bring-up. The **[AudioModule](../moonmodules/core/AudioModule.md)** ES8311 path is implemented +**[I2cScanModule](../moonmodules/core/moxygen/I2cScanModule.md)** on the codec bus (SDA 51 / SCL 50) for +I2C bring-up. The **[AudioModule](../moonmodules/core/moxygen/AudioModule.md)** ES8311 path is implemented — the codec seam configures the ES8311 over I2C (codec reachable, ACK at 0x18) and AudioModule reads the I2S mic. End-to-end mic validation depends on confirming MCLK at GPIO52; the S31 entry keeps **Audio** under `planned` until that bench check passes, so the installer advertises only what's confirmed working. The other board capabilities (Ethernet, Bluetooth, SD, USB host, …) -likewise live in the entry's `planned` list — see the S31 entry in `docs/install/deviceModels.json`. +likewise live in the entry's `planned` list — see the S31 entry in `web-installer/deviceModels.json`. diff --git a/docs/testing.md b/docs/testing.md index cae3c299..955e27ab 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -54,11 +54,11 @@ A test lives under the subfolder of its **primary** `@module`'s source domain (e ### Host-side tests (Python + JS) -The C++ `ctest` / scenario suites can't reach the **Python** (MoonDeck, build scripts) or **JS** (web installer) code, so those get their own host-side unit tier — `test/python/` (pytest, run `uv run --with pytest --with pyserial pytest test/python`) and `test/js/` (Node's built-in runner, run `node --test "test/js/**/*.test.mjs"`; no `package.json`/`npm install`). Both run in `.github/workflows/test.yml` on every PR and are commit gates (CLAUDE.md Event 1, gate 10) when `scripts/` / `docs/install/` / the test dirs change. Python test files carry their deps in a PEP-723 `# /// script` block (the repo's convention — there's no central `pyproject.toml`); pyserial is a dep only because `improv_provision.py`'s import guard exits without it, not because the frame logic needs it. +The C++ `ctest` / scenario suites can't reach the **Python** (MoonDeck, build scripts) or **JS** (web installer) code, so those get their own host-side unit tier — `test/python/` (pytest, run `uv run --with pytest --with pyserial pytest test/python`) and `test/js/` (Node's built-in runner, run `node --test "test/js/**/*.test.mjs"`; no `package.json`/`npm install`). Both run in `.github/workflows/test.yml` on every PR and are commit gates (CLAUDE.md Event 1, gate 10) when `scripts/` / `web-installer/` / the test dirs change. Python test files carry their deps in a PEP-723 `# /// script` block (the repo's convention — there's no central `pyproject.toml`); pyserial is a dep only because `improv_provision.py`'s import guard exits without it, not because the frame logic needs it. #### What's covered today: the Improv frame wire format -The Improv serial frame is implemented **three times** — device C++ (`src/core/ImprovFrame.h`), Python (`scripts/build/improv_provision.py`), and installer JS (`docs/install/improv-frame.js`) — so a drift in any one silently breaks provisioning. Both suites assert the **same golden vector** so the Python and JS builders provably agree (hand-verified against the C++ sum-mod-256 checksum): +The Improv serial frame is implemented **three times** — device C++ (`src/core/ImprovFrame.h`), Python (`scripts/build/improv_provision.py`), and installer JS (`web-installer/improv-frame.js`) — so a drift in any one silently breaks provisioning. Both suites assert the **same golden vector** so the Python and JS builders provably agree (hand-verified against the C++ sum-mod-256 checksum): ``` buildImprovFrame(type=0x03, payload=[0x01]) == 49 4d 50 52 4f 56 01 03 01 01 e3 @@ -87,7 +87,9 @@ buildImprovFrame(type=0x03, payload=[0x01]) == 49 4d 50 52 4f 56 01 03 01 01 e The JS suite proves the installer *chunks* an op correctly; the **device side that reassembles those chunks** is pinned by the C++ `unit_ImprovOpReassembler` suite (`src/core/ImprovOpReassembler.h`, the pure state machine behind the device's `APPLY_OP` handler — extracted from `platform_esp32_improv.cpp` so it's desktop-testable). It covers the full receive contract: in-order multi-chunk reassembly + NUL-termination, **duplicate-chunk rejection** and **out-of-order/skipped-seq rejection** (the guard against an installer retry corrupting the buffer), **overflow** rejection at the buffer-minus-NUL boundary, mid-stream `seq 0` abandoning a partial op, and clean recovery after every error. Encode (JS) + reassemble (C++) together prove APPLY_OP end to end without hardware. -**`test/python/test_installer_manifests.py`** (pytest) — pins the web installer's per-release file contract. For every `ships: true` firmware in `docs/install/firmwares.json` it runs `scripts/build/generate_manifest.py` (with a synthetic `flasher_args.json`, so no firmware build is needed) and asserts the manifest is valid (a `chipFamily` + non-empty `parts[]`) AND that **every part filename matches one of the globs the release workflow stages onto Pages** (`firmware-*.bin` / `shared-ota-data.bin` / `partition-table-*.bin`). A manifest that names a file outside those globs points at something the deploy never stages → the installer 404s at fetch-firmware (the failure that shipped a broken v2.0.0 installer). The test guards the manifest-generation ↔ staged-files contract; the *deploy mechanics* that stage them (per-tag, in `release.yml`) are workflow shell logic a unit test can't reach, so the two are complementary. +**`test/python/test_installer_manifests.py`** (pytest) — pins the web installer's per-release file contract. For every `ships: true` firmware in `web-installer/firmwares.json` it runs `scripts/build/generate_manifest.py` (with a synthetic `flasher_args.json`, so no firmware build is needed) and asserts the manifest is valid (a `chipFamily` + non-empty `parts[]`) AND that **every part filename matches one of the globs the release workflow stages onto Pages** (`firmware-*.bin` / `shared-ota-data.bin` / `partition-table-*.bin`). A manifest that names a file outside those globs points at something the deploy never stages → the installer 404s at fetch-firmware (the failure that shipped a broken v2.0.0 installer). The test guards the manifest-generation ↔ staged-files contract; the *deploy mechanics* that stage them (per-tag, in `release.yml`) are workflow shell logic a unit test can't reach, so the two are complementary. + +**`test/python/test_check_specs_drift.py`** (pytest) — pins the two spec-drift guards in `scripts/check/check_specs.py` (the spec-check commit gate). Some facts live in both the `.h` and the module doc in different forms: a control's **numeric range** (`addUint8("floor", floor, 0, 255)` vs the prose "noise floor (0–255)") and the **author/source URL** (`// Author: … — ` vs the `Origin:` markdown link). Neither can be single-sourced — they're the same fact for two audiences — so instead the gate *validates* them: if the doc restates a control's range and it conflicts with the `.h`, or an `.h` author URL is missing from the doc, the spec check flags it. The checks are block-scoped on the consolidated catalog pages (a control name shared across modules, `fps`/`fadeRate`, matches only its own module's block), and tolerant of the human range spellings (`1–8` / `1-8` / `1 to 8`) — this suite pins both the catch and the no-false-alarm behaviour. MoonDeck's pure logic (catalog reverse-lookup, state migration) and the installer's op-walk / storage are the next host-side candidates as they accrete regression risk. diff --git a/docs/tests/scenario-tests.md b/docs/tests/scenario-tests.md deleted file mode 100644 index 8530d721..00000000 --- a/docs/tests/scenario-tests.md +++ /dev/null @@ -1,1753 +0,0 @@ -# Scenario Tests - -Auto-generated from `test/scenarios/{core,light}/scenario_*.json` by `scripts/docs/generate_test_docs.py`. **Do not edit by hand** — update the JSON file's top-level fields and per-step `description` / `bounds` / `contract` / `observed` instead, then regenerate. - -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 three pins one at a time (the install fan-out's exact add-then-configure sequence — the web installer / MoonDeck / OTA picker add the AudioModule then POST wsPin/sdPin/sckPin as separate control writes from a catalog entry's modules+controls), 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). The per-pin sequence proves the self-correcting partial-fill: the first two writes leave a pin unset so AudioModule's guard makes the rebuild a no-op, the third completes the set — the pipeline never stalls through any of it. 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` | — / 15,625-125,000 | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-12 → 2026-07-01 - -#### `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` | — / 15,873-125,000 | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-12 → 2026-07-01 - -#### `measure-pins-configured` (measure) 📏 - -All three mic pins set via the sequential install-fan-out order: pipeline still renders through the full add-then-configure flow a catalog inject performs (add AudioModule, then wsPin/sdPin/sckPin one at a time). - -**Setup** (preceding non-measured steps): -- `configure-ws-pin` (set_control) — Set the first mic pin (wsPin). This mirrors the install fan-out: a catalog entry's Audio pins arrive as separate /api/control writes, one per pin. After this single write wsPin is set but sdPin/sckPin are still 0, so AudioModule's unset-pin guard short-circuits before audioMicInit (no I2S init attempted) and the rebuild is a cheap no-op. The pipeline must keep rendering through it. -- `configure-sd-pin` (set_control) — Second pin (sdPin). Still one pin unset (sckPin=0), so still guarded — the second cheap no-op rebuild. Proves the partial-pin-fill sequence the install injection produces never disturbs the running pipeline. -- `configure-sck-pin` (set_control) — Third and final pin (sckPin). Now all three are set, so the guard passes and a real mic init is attempted. On host hasI2sMic is false so the mic stays inert regardless, but this is the write that completes the install-injected pin set; the buildState rebuild must not disturb the running pipeline. - -**Bounds**: -- FPS ≥ 1 (absolute) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | — / 15,873-125,000 | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-13 → 2026-07-01 - -#### `measure-consumer-live` (measure) 📏 - -Pipeline renders with the producer + consumer both wired. - -**Setup** (preceding non-measured steps): -- `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` | — / 13,889-125,000 | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-12 → 2026-07-01 - -#### `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` | — / 13,514-125,000 | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-12 → 2026-07-01 - -#### `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` | — / 15,873-125,000 | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-12 → 2026-07-01 - -## DevicesModule - -### scenario_DevicesModule_scan - -`test/scenarios/core/scenario_DevicesModule_scan.json` — Trigger the device-discovery sweep repeatedly on a running device and confirm the render loop survives every sweep. Pins the robustness principle for DevicesModule: pressing the `scan` button (a Button control on the Devices submodule of Network) re-runs the subnet sweep, whose HTTP probes block the render task up to the probe timeout per tick. No press, sweep state, or completion may crash or wedge the tick. Runs live only — discovery needs a real LAN to probe and the module only exists on a connected device, so the in-process desktop runner SKIPs it; on a device it presses the button over HTTP. The bound checks FPS stays within range across repeated sweeps (a sweep is a transient cost, not a permanent degradation), proving the scan never leaves the render loop in a wedged state. (Background: the sweep is boot-only + manual precisely because the blocking probe must not run continuously on the render task — see DevicesModule.md.) - -**Mode**: `mutate` · **live-only** (skipped in-process) - -#### `scan-1` (set_control) 📏 - -First manual sweep. Baseline: the device renders while a discovery sweep runs. - -#### `scan-2` (set_control) 📏 - -Re-trigger the sweep while the previous one's state is still settling — confirms a re-press mid-cycle doesn't wedge the loop. - -#### `scan-3` (set_control) 📏 - -Third sweep, bounded: FPS must stay within 20% of the first (a sweep is a transient cost; repeated scans must not permanently degrade the render loop). - -**Bounds**: -- FPS ≥ 80% of baseline - -## GridLayout - -### scenario_GridLayout_resize - -`test/scenarios/light/scenario_GridLayout_resize.json` — Resize the grid while the pipeline is running and verify it reallocates cleanly under memory pressure. Lowers to 128x64 (release memory), increases to 128x128 (heaviest config: mirror + LUT). Each measured step captures tick/FPS/heap so the runner reports the degrade behaviour. - -**Mode**: `mutate` · **Also touches**: MultiplyModifier, Layer - -#### `size-128x128` (set_control) 📏 - -Set grid height to 128 (alongside default width 128). Measures the heaviest config as the baseline for the next two steps. - -**Setup** (preceding non-measured steps): -- `canvas-clear-layers` (clear_children) — Self-canvas: clear+rebuild the pipeline this scenario assumes, so it runs from any device state (the perf scenarios' pattern). Pre-wired apparatus (Preview/Board) survives clear_children. On the in-process runner the fixture already built the tree; clearing then rebuilding is harmless there and makes the live run order-independent. -- `canvas-clear-layouts` (clear_children) -- `canvas-clear-drivers` (clear_children) -- `canvas-grid` (add_module) -- `canvas-layer` (add_module) -- `canvas-noise` (add_module) -- `canvas-mirror` (add_module) -- `canvas-artnet` (add_module) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 4.5 | — / 83KB | — / 48KB | -| `esp32-eth` | — / 10.7-10.8 | — / 132KB | — / 48KB-52KB | -| `esp32-eth-wifi` | ≥ 10.0 / 12.4 | ≥ 103KB / 93KB | — / 48KB | -| `esp32p4-eth` | — / 739-880 | — / 33206KB-33218KB | — / 376KB | -| `esp32s3-n16r8` | — / 106-217 | — / 8315KB-8321KB | — / 104KB-108KB | -| `pc-macos` | ≥ 8,333 / 3,534-10,526 | unlimited / unlimited | — / unlimited | -| `pc-windows` | — / 3,413-4,566 | — / 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 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-22 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 -- `pc-windows`: observed 2026-06-07 - -#### `shrink-to-128x64` (set_control) 📏 - -Shrink to 128x64. Measured: tick/heap captured so the runner reports the realloc behaviour against each target's contract. (The old relative-to-baseline FPS bound was removed — it compared against the runner's idle pre-scenario baseline, not the prior render step, so it false-failed on fast boards like the P4 where idle FPS dwarfs render FPS.) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 11.1 | — / 63KB | — / 17KB | -| `esp32-eth` | — / 26.4-26.5 | — / 114KB | — / 48KB | -| `esp32-eth-wifi` | ≥ 22.2 / 31.8 | ≥ 83KB / 75KB | — / 24KB | -| `esp32p4-eth` | — / 1,527-1,739 | — / 33214KB-33226KB | — / 376KB | -| `esp32s3-n16r8` | — / 415-787 | — / 8324KB-8331KB | — / 100KB-112KB | -| `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 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-22 -- `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) 📏 - -Grow back to 128x128. Measured: confirms the heap can return to the heavy baseline after a shrink. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 4.0 | — / 83KB | — / 52KB | -| `esp32-eth` | — / 10.4 | — / 132KB | — / 48KB | -| `esp32-eth-wifi` | ≥ 10.0 / 12.2 | ≥ 103KB / 93KB | — / 52KB | -| `esp32p4-eth` | — / 762-875 | — / 33206KB-33218KB | — / 376KB | -| `esp32s3-n16r8` | — / 132-251 | — / 8312KB-8322KB | — / 100KB-112KB | -| `pc-macos` | ≥ 8,333 / 3,257-10,204 | unlimited / unlimited | — / unlimited | -| `pc-windows` | — / 3,436-4,608 | — / 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 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-22 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 -- `pc-windows`: observed 2026-06-07 - -## Layer - -### scenario_Layer_base_pipeline - -`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, NetworkSendDriver - -#### `add-artnet` (add_module) 📏 - -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. -- `add-grid` (add_module) — Add a 128x128 GridLayout child to Layouts. Set explicitly (the module default is 16x16x1) so the tick is above the host's microsecond clock resolution — a 16x16 grid renders in <1us on desktop, flooring tick to 0. -- `add-layer` (add_module) — Add a top-level Layer wired to the Layouts container, RGB (3 channels per light). -- `add-rainbow` (add_module) — Add RainbowEffect as the Layer's only effect. -- `add-driver-group` (add_module) — Add a top-level Drivers container wired to the Layer's output buffer. - -**Bounds**: -- FPS ≥ 80% of baseline -- FPS × lights ≥ 294,912 - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | ≥ 20,000 / 4,115-— | unlimited / unlimited | — / unlimited | -| `pc-windows` | — / 7,874-8,475 | — / unlimited | — / unlimited | - -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-07-01 -- `pc-windows`: observed 2026-06-07 - -### scenario_Layer_memory_1to1 - -`test/scenarios/light/scenario_Layer_memory_1to1.json` — Verify that an unshuffled 1:1 mapping (no modifier) uses no LUT and no driver buffer. Catches a regression where Layer would allocate a passthrough LUT for the identity case. - -**Mode**: `construct` · **Also touches**: MappingLUT, BlendMap - -#### `add-artnet` (add_module) 📏 - -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. -- `add-grid` (add_module) — Add a 16x16 GridLayout. -- `add-layer` (add_module) — Add a Layer wired to Layouts (RGB). -- `add-rainbow` (add_module) — Add RainbowEffect as the Layer's effect. -- `add-driver-group` (add_module) — Add a Drivers container wired to the Layer. - -**Bounds**: -- FPS ≥ 80% of baseline - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | ≥ 20,000 / 12,500-— | unlimited / unlimited | — / unlimited | -| `pc-windows` | — / 500,000-1,000,000 | — / unlimited | — / unlimited | - -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 -- `pc-windows`: observed 2026-06-07 - -### scenario_modifier_chain - -`test/scenarios/light/scenario_modifier_chain.json` — Stack TWO modifiers on one Layer (Region then Multiply) and verify the chain composes live end-to-end — the capability the old single-modifier engine couldn't do. Prepares its own canvas: Layout(Grid 32x32) + Layer + NoiseEffect + Region(0..50) + Multiply(2x), measures the composite, then adds a third (Checkerboard mask) and measures again, then removes the middle modifier and measures — exercising add/remove on a multi-modifier chain. A broken fold (null buffer, wrong light count, crash on a disabled/removed stage) shows up as a failed measure. The fold composition + order semantics are pinned by unit_Layer_modifier_chain; this is the live end-to-end gate. - -**Mode**: `mutate` · **Also touches**: RegionModifier, MultiplyModifier, CheckerboardModifier, RotateModifier, NoiseEffect, Layouts, GridLayout, Drivers, NetworkSendDriver - -#### `add-mask` (add_module) 📏 - -Add a third modifier (Checkerboard mask) on top of the chain — a 3-deep fold. Measure that the deeper chain still renders. - -**Setup** (preceding non-measured steps): -- `region-then-multiply` (measure) — Two stacked modifiers: Region(top-left quarter) then Multiply(2x mirror) compose into one mapping. Measure the live composite. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | — / 18,182-200,000 | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-26 → 2026-07-01 - -#### `remove-middle` (remove_module) 📏 - -Remove the middle modifier (Multiply) — the chain re-folds with Region then Checkerboard, no stale state. Measure. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | — / 6,536-55,556 | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-26 → 2026-07-01 - -#### `add-live-rotate` (add_module) 📏 - -Add a DYNAMIC Rotate on top of the static chain — its modifyLive runs the per-frame remap pass over the composed buffer. Verifies a static chain + a live modifier coexist (the buffer is remapped each frame on top of the baked Region/Checkerboard mapping) without a crash or null buffer. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | — / 5,128-31,250 | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-26 → 2026-07-01 - -### scenario_modifier_swap - -`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, NetworkSendDriver, PreviewDriver - -#### `multiply-1` (measure) 📏 - -Multiply modifier active — pipeline live, LUT folds the grid. - -**Setup** (preceding non-measured steps): -- `shrink-w` (set_control) -- `shrink-h` (set_control) -- `clear-layers` (clear_children) -- `clear-layouts` (clear_children) -- `build-grid` (add_module) -- `build-layer` (add_module) -- `build-fx` (add_module) -- `build-mod` (add_module) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 1,783-2,179 | — / 145KB | — / 108KB | -| `esp32-eth` | — / 1,580-7,752 | — / 172KB-225KB | — / 76KB-108KB | -| `esp32p4-eth` | — / 5,587-6,061 | — / 33243KB | — / 376KB | -| `esp32s3-n16r8` | — / 1,773-2,571 | — / 8350KB | — / 92KB | -| `pc-macos` | — / 25,000-166,667 | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-25 -- `esp32-eth`: observed 2026-06-07 → 2026-06-08 -- `esp32p4-eth`: observed 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-25 -- `pc-macos`: observed 2026-06-07 → 2026-06-30 - -#### `checkerboard` (measure) 📏 - -Checkerboard modifier active — masks half the lights; pipeline stays live (driver buffer non-null). - -**Setup** (preceding non-measured steps): -- `swap-to-checker` (replace_module) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 892-922 | — / 145KB | — / 108KB | -| `esp32-eth` | — / 769-990 | — / 170KB-225KB | — / 76KB-108KB | -| `esp32p4-eth` | — / 2,747-2,762 | — / 33242KB | — / 376KB | -| `esp32s3-n16r8` | — / 924-943 | — / 8349KB | — / 92KB | -| `pc-macos` | — / 4,184-58,824 | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-25 -- `esp32-eth`: observed 2026-06-07 → 2026-06-08 -- `esp32p4-eth`: observed 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-25 -- `pc-macos`: observed 2026-06-07 → 2026-07-01 - -#### `multiply-2` (measure) 📏 - -Back to Multiply — replace round-trips cleanly, pipeline live again. - -**Setup** (preceding non-measured steps): -- `swap-to-multiply` (replace_module) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 2,079-2,208 | — / 145KB | — / 108KB | -| `esp32-eth` | — / 1,587-2,278 | — / 169KB-225KB | — / 76KB-108KB | -| `esp32p4-eth` | — / 6,329-6,410 | — / 33243KB | — / 376KB | -| `esp32s3-n16r8` | — / 2,146-2,604 | — / 8349KB-8350KB | — / 92KB | -| `pc-macos` | — / 10,101-166,667 | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-25 -- `esp32-eth`: observed 2026-06-07 → 2026-06-08 -- `esp32p4-eth`: observed 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-25 -- `pc-macos`: observed 2026-06-07 → 2026-07-01 - -### scenario_perf_full - -`test/scenarios/light/scenario_perf_full.json` — Comprehensive incremental performance check (the SLOW, on-device companion to scenario_perf_light). Mutate mode + canvas-preparing: clear_children whatever the device already had (pre-wired apparatus like PreviewDriver/Board survives — clear_children only drops user-editable children), rebuild a known minimal tree, then add one subsystem at a time — audio, device discovery, a modifier, then EVERY output driver this board has (each optional + capped to 64 output LEDs so its per-frame cost is comparable, not its transmit-all-16K time), then a network driver — measuring the tick/heap delta after each so each subsystem's cost is isolated. Then sweep the grid 16²→32²→64²→128² (16K) for both a LIGHT effect (Spiral) and a HEAVY one (Noise) to bracket the compute range across sizes. LED drivers are platform-gated (RMT on classic/S3, LCD on S3, Parlio on P4; none on desktop) so each driver step is optional:true and skipped where absent — the all-drivers comparison is assembled across boards (S3 gives RMT vs LCD, P4 gives RMT vs Parlio). Subsumes the old scenario_Layer_buildup (incremental module cost), scenario_GridLayout_grid_sizes (grid sweep), and scenario_AllEffects_grid_sizes (per-effect size sweep, here reduced to a light/heavy bracket). Runs minutes on a device; not a per-commit gate. - -**Mode**: `mutate` · **Also touches**: Layouts, GridLayout, Drivers, PreviewDriver, NetworkSendDriver, RmtLedDriver, LcdLedDriver, ParlioLedDriver, MultiplyModifier, SpiralEffect, NoiseEffect - -#### `measure-minimal` (measure) 📏 - -Bare minimum at 16²: Grid + Layer + Spiral, no output driver, audio/discovery still on as the device ships. The floor for the subsystem-cost diffs below. - -**Setup** (preceding non-measured steps): -- `clear-layers` (clear_children) — Start clean: drop whatever effects/modifiers/layouts/drivers the device had (pre-wired Preview survives). -- `clear-layouts` (clear_children) -- `clear-drivers` (clear_children) -- `build-grid` (add_module) -- `build-layer` (add_module) -- `build-fx` (add_module) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 7,692-8,929 | — / 134KB-147KB | — / 108KB | -| `esp32p4-eth` | — / 14,925-17,544 | — / 33226KB-33245KB | — / 376KB | -| `esp32s3-n16r8` | — / 5,376-9,009 | — / 8340KB-8352KB | — / 92KB-112KB | -| `pc-macos` | — / 83,333-— | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-26 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-no-audio` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `disable-audio` (set_control) — Disable AudioModule (stops I2S sampling in loop()). Diff vs measure-minimal = the audio subsystem's per-tick cost (device only; optional). - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 8,621-9,901 | — / 134KB-147KB | — / 108KB | -| `esp32p4-eth` | — / 18,182-18,868 | — / 33228KB-33245KB | — / 376KB | -| `esp32s3-n16r8` | — / 8,065-9,901 | — / 8338KB-8352KB | — / 92KB-112KB | -| `pc-macos` | — / 125,000-— | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-26 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-quiet` (measure) 📏 - -Quiet baseline: render-only, audio + discovery off. The cleanest render floor; the per-driver costs below diff against this. - -**Setup** (preceding non-measured steps): -- `disable-devices` (set_control) — Disable the Devices module (stops the blocking HTTP discovery sweep in loop1s()). Diff = the discovery cost (device only; optional). - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 7,246-9,901 | — / 131KB-146KB | — / 108KB | -| `esp32p4-eth` | — / 17,544-18,519 | — / 33226KB-33245KB | — / 376KB | -| `esp32s3-n16r8` | — / 7,752-9,901 | — / 8337KB-8352KB | — / 92KB-112KB | -| `pc-macos` | — / 142,857-— | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-26 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-modifier` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `add-modifier` (add_module) — +MultiplyModifier: allocates the mapping LUT. Diff = modifier + LUT cost. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 2,786-3,610 | — / 130KB-145KB | — / 108KB | -| `esp32p4-eth` | — / 8,772-10,638 | — / 33224KB-33243KB | — / 376KB | -| `esp32s3-n16r8` | — / 3,413-4,237 | — / 8336KB-8350KB | — / 92KB-112KB | -| `pc-macos` | — / 333,333-— | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-26 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-preview` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `remove-modifier` (remove_module) — Drop the modifier so the driver-cost measurements below are on the plain 1:1 pipeline (drivers, not the LUT, are what we compare here). -- `add-preview` (add_module) — +PreviewDriver. Optional: on a device the pre-wired Preview survives clear_children so it's already present (the add is skipped); on the in-process desktop runner there's no apparatus, so this adds it. Either way the next measure includes Preview. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 8,696-9,524 | — / 123KB-147KB | — / 108KB | -| `esp32p4-eth` | — / 15,873-18,182 | — / 33228KB-33245KB | — / 376KB | -| `esp32s3-n16r8` | — / 8,065-9,434 | — / 8335KB-8352KB | — / 92KB-112KB | -| `pc-macos` | — / 83,333-— | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-25 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-network` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `add-network-driver` (add_module) — +NetworkSendDriver (ArtNet/DDP — works on every platform). Diff = the network output cost (capped by the 16² grid here). - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 6,098-7,194 | — / 131KB-145KB | — / 108KB | -| `esp32p4-eth` | — / 14,493-17,544 | — / 33226KB-33244KB | — / 376KB | -| `esp32s3-n16r8` | — / 6,452-8,065 | — / 8334KB-8351KB | — / 84KB-112KB | -| `pc-macos` | — / 142,857-— | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-26 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-26 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-rmt` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `remove-network-driver` (remove_module) -- `add-rmt-driver` (add_module) — +RmtLedDriver capped to 64 output LEDs (one pin, ledsPerPin=64). Optional — classic + S3. Diff = the RMT per-frame cost at a fixed 64-LED output. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 6,579-9,174 | — / 106KB-122KB | — / 84KB-108KB | -| `esp32p4-eth` | — / 15,873-17,857 | — / 33200KB-33221KB | — / 376KB | -| `esp32s3-n16r8` | — / 7,194-9,346 | — / 8307KB-8328KB | — / 84KB-112KB | -| `pc-macos` | — / 32,258-— | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-25 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-26 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-lcd` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `remove-rmt-driver` (remove_module) -- `add-lcd-driver` (add_module) — +LcdLedDriver capped to 64 LEDs on lane 0 (i80 needs all 8 data pins; unused lanes get 0 LEDs). Optional — S3 only. Diff = the LCD_CAM per-frame cost. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 8,403-9,901 | — / 126KB-147KB | — / 108KB | -| `esp32p4-eth` | — / 15,873-17,857 | — / 33225KB-33245KB | — / 376KB | -| `esp32s3-n16r8` | — / 7,042-9,259 | — / 8333KB-8352KB | — / 88KB-112KB | -| `pc-macos` | — / 76,923-— | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-25 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-parlio` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `remove-lcd-driver` (remove_module) -- `add-parlio-driver` (add_module) — +ParlioLedDriver capped to 64 LEDs on lane 0. Optional — P4 only. Diff = the Parlio per-frame cost. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 8,475-9,901 | — / 135KB-147KB | — / 108KB | -| `esp32p4-eth` | — / 15,873-17,857 | — / 33225KB-33245KB | — / 376KB | -| `esp32s3-n16r8` | — / 7,692-9,434 | — / 8338KB-8352KB | — / 92KB-112KB | -| `pc-macos` | — / 90,909-— | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-25 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-light-16` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `remove-parlio-driver` (remove_module) -- `add-preview-for-sweep` (add_module) — Re-add PreviewDriver as the output for the grid sweep (the per-driver adds above each removed their driver; Preview is the cheap, every-board output for a pure-render size curve). -- `light-16-w` (set_control) — Grid sweep, LIGHT effect (Spiral is already FX). -- `light-16-h` (set_control) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 6,711-9,804 | — / 134KB-147KB | — / 108KB | -| `esp32p4-eth` | — / 15,385-18,868 | — / 33226KB-33245KB | — / 376KB | -| `esp32s3-n16r8` | — / 8,403-9,901 | — / 8336KB-8352KB | — / 92KB-112KB | -| `pc-macos` | — / 100,000-— | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-25 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-light-32` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `light-32-w` (set_control) -- `light-32-h` (set_control) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 2,801-3,367 | — / 134KB-144KB | — / 108KB | -| `esp32p4-eth` | — / 7,246-7,576 | — / 33225KB-33243KB | — / 376KB | -| `esp32s3-n16r8` | — / 3,049-3,597 | — / 8331KB-8350KB | — / 92KB-112KB | -| `pc-macos` | — / 35,714-1,000,000 | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-26 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-light-64` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `light-64-w` (set_control) -- `light-64-h` (set_control) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 870-928 | — / 125KB-135KB | — / 108KB | -| `esp32p4-eth` | — / 2,008-2,232 | — / 33218KB-33234KB | — / 376KB | -| `esp32s3-n16r8` | — / 894-1,011 | — / 8312KB-8341KB | — / 88KB-112KB | -| `pc-macos` | — / 9,091-333,333 | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-26 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-light-128` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `light-128-w` (set_control) -- `light-128-h` (set_control) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 224-238 | — / 89KB-99KB | — / 62KB | -| `esp32p4-eth` | — / 515-573 | — / 33182KB-33198KB | — / 376KB | -| `esp32s3-n16r8` | — / 114-134 | — / 8291KB-8305KB | — / 92KB-112KB | -| `pc-macos` | — / 2,165-62,500 | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-25 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-heavy-16` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `swap-heavy` (replace_module) — Swap to the HEAVY effect (Noise) and repeat the sweep — the upper bracket of per-pixel compute. -- `heavy-16-w` (set_control) -- `heavy-16-h` (set_control) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 990-1,224 | — / 136KB-147KB | — / 108KB | -| `esp32p4-eth` | — / 2,865-3,367 | — / 33229KB-33245KB | — / 376KB | -| `esp32s3-n16r8` | — / 1,100-1,361 | — / 8342KB-8352KB | — / 92KB-112KB | -| `pc-macos` | — / 47,619-333,333 | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-25 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-26 -- `pc-macos`: observed 2026-06-17 → 2026-06-27 - -#### `measure-heavy-32` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `heavy-32-w` (set_control) -- `heavy-32-h` (set_control) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 306-314 | — / 134KB-144KB | — / 108KB | -| `esp32p4-eth` | — / 799-898 | — / 33227KB-33243KB | — / 376KB | -| `esp32s3-n16r8` | — / 290-356 | — / 8339KB-8350KB | — / 92KB-112KB | -| `pc-macos` | — / 11,765-71,429 | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-26 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-06-27 - -#### `measure-heavy-64` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `heavy-64-w` (set_control) -- `heavy-64-h` (set_control) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 73.8-79.4 | — / 125KB-135KB | — / 108KB | -| `esp32p4-eth` | — / 196-229 | — / 33218KB-33234KB | — / 376KB | -| `esp32s3-n16r8` | — / 85.2-90.3 | — / 8330KB-8341KB | — / 92KB-112KB | -| `pc-macos` | — / 2,119-16,129 | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-26 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-06-27 - -#### `measure-heavy-128` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `heavy-128-w` (set_control) -- `heavy-128-h` (set_control) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 16.0-19.0 | — / 89KB-99KB | — / 62KB | -| `esp32p4-eth` | — / 53.7-57.4 | — / 33182KB-33198KB | — / 376KB | -| `esp32s3-n16r8` | — / 19.2-20.8 | — / 8293KB-8305KB | — / 92KB-112KB | -| `pc-macos` | — / 584-3,676 | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-25 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-mod-16` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `add-modifier-for-sweep` (add_module) — Re-add the MultiplyModifier on the HEAVY effect and sweep grid sizes — the diff vs the matching measure-heavy-N step (no modifier) is the modifier's PER-FRAME cost at each grid, answering whether the ~180µs seen at 16² scales with pixel count or is fixed overhead. -- `mod-16-w` (set_control) -- `mod-16-h` (set_control) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 2,020-2,222 | — / 135KB-145KB | — / 108KB | -| `esp32p4-eth` | — / 5,263-6,494 | — / 33224KB-33243KB | — / 376KB | -| `esp32s3-n16r8` | — / 2,193-2,618 | — / 8340KB-8350KB | — / 92KB-112KB | -| `pc-macos` | — / 200,000-1,000,000 | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-25 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-mod-32` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `mod-32-w` (set_control) -- `mod-32-h` (set_control) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 547-586 | — / 130KB-140KB | — / 108KB | -| `esp32p4-eth` | — / 1,631-1,876 | — / 33218KB-33237KB | — / 376KB | -| `esp32s3-n16r8` | — / 600-710 | — / 8329KB-8344KB | — / 92KB-112KB | -| `pc-macos` | — / 5,882-333,333 | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-26 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-26 -- `pc-macos`: observed 2026-06-17 → 2026-06-25 - -#### `measure-mod-64` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `mod-64-w` (set_control) -- `mod-64-h` (set_control) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 144-149 | — / 111KB-122KB | — / 96KB-100KB | -| `esp32p4-eth` | — / 438-486 | — / 33194KB-33210KB | — / 376KB | -| `esp32s3-n16r8` | — / 119-162 | — / 8307KB-8317KB | — / 92KB-112KB | -| `pc-macos` | — / 9,434-71,429 | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-26 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-mod-128` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `mod-128-w` (set_control) -- `mod-128-h` (set_control) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 29.8-35.1 | — / 36KB-47KB | — / 24KB-26KB | -| `esp32p4-eth` | — / 86.3-102 | — / 33089KB-33105KB | — / 376KB | -| `esp32s3-n16r8` | — / 16.8-35.6 | — / 8202KB-8212KB | — / 92KB-112KB | -| `pc-macos` | — / 3,378-16,393 | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-25 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -### scenario_perf_light - -`test/scenarios/light/scenario_perf_light.json` — Fast incremental performance check: start from the bare minimum render pipeline and add one thing at a time, measuring the tick/heap delta each step, so a regression shows up as a per-step jump. The LIGHT companion to scenario_perf_full — it stays small (≤64²) and driver-free so it runs in seconds. Mutate mode + canvas-preparing: the steps clear_children whatever Layouts/Layers/Drivers the device already had (the pre-wired apparatus like PreviewDriver/Board survives — clear_children only drops user-editable children) and rebuild a known tree, so it runs from any starting state and always measures the same minimal pipeline. Order: (1) minimal = Grid(16²)+Layer+a LIGHT effect (Spiral, a light effect), no modifier/driver/audio/discovery; (2) +MultiplyModifier (adds the mapping LUT — the heavy memory path); (3) +PreviewDriver; (4) swap to a HEAVY effect (Noise) to bracket the compute range; (5) grid 16²→32²→64² to show the size scaling. Full 128²/16K sweep, real LED/network drivers, audio+discovery cost: see scenario_perf_full. - -**Mode**: `mutate` · **Also touches**: Layouts, GridLayout, Drivers, PreviewDriver, SpiralEffect, NoiseEffect, MultiplyModifier - -#### `measure-minimal` (measure) 📏 - -Bare minimum: Grid(16²) + Layer + Spiral (light effect). No modifier, no driver. The render floor everything else is measured against. - -**Setup** (preceding non-measured steps): -- `disable-audio` (set_control) — Quiet I2S sampling so it can't pollute the tick (optional — device only). -- `disable-devices` (set_control) — Stop the blocking HTTP discovery sweep (optional — device only). -- `clear-layers` (clear_children) — Drop whatever effects/modifiers the device had — start clean. -- `clear-layouts` (clear_children) -- `clear-drivers` (clear_children) — Drop any output driver; the pre-wired PreviewDriver is non-deletable and survives. -- `build-grid` (add_module) -- `build-layer` (add_module) -- `build-fx` (add_module) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 6,173-8,850 | — / 125KB-147KB | — / 108KB | -| `esp32p4-eth` | — / 13,699-18,519 | — / 33228KB-33246KB | — / 376KB | -| `esp32s3-n16r8` | — / 5,814-8,850 | — / 8316KB-8347KB | — / 80KB-104KB | -| `pc-macos` | — / 125,000-— | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-25 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-with-modifier` (measure) 📏 - -Cost of the modifier + LUT over the minimal pipeline. Heap delta vs measure-minimal is the LUT allocation. - -**Setup** (preceding non-measured steps): -- `add-modifier` (add_module) — +MultiplyModifier: allocates the mapping LUT (the heavy memory path vs the 1:1 no-LUT case). - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 3,077-9,709 | — / 131KB-147KB | — / 108KB | -| `esp32p4-eth` | — / 8,621-10,309 | — / 33226KB-33243KB | — / 376KB | -| `esp32s3-n16r8` | — / 3,195-4,032 | — / 8330KB-8345KB | — / 92KB-100KB | -| `pc-macos` | — / 500,000-— | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-25 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-with-preview` (measure) 📏 - -PreviewDriver is the pre-wired apparatus — it survives clear_children and is already attached, so the measures above already include it (no add step needed; adding a second Preview is rejected). This is a stable repeat of the effect+modifier config for run-to-run variance. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 3,067-9,804 | — / 132KB-146KB | — / 108KB | -| `esp32p4-eth` | — / 10,417-10,753 | — / 33226KB-33243KB | — / 376KB | -| `esp32s3-n16r8` | — / 3,802-4,274 | — / 8330KB-8345KB | — / 84KB-100KB | -| `pc-macos` | — / 333,333-— | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-25 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-heavy-16` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `swap-heavy-fx` (replace_module) — Swap the light effect for a HEAVY one (Noise — simplex per pixel) to bracket the compute range at the same 16². - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 1,142-3,268 | — / 131KB-146KB | — / 108KB | -| `esp32p4-eth` | — / 5,556-6,494 | — / 33224KB-33243KB | — / 376KB | -| `esp32s3-n16r8` | — / 2,299-2,506 | — / 8332KB-8342KB | — / 88KB-100KB | -| `pc-macos` | — / 200,000-1,000,000 | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-25 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-heavy-32` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `grid-32-w` (set_control) -- `grid-32-h` (set_control) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 265-826 | — / 130KB-144KB | — / 108KB | -| `esp32p4-eth` | — / 1,603-1,880 | — / 33221KB-33237KB | — / 376KB | -| `esp32s3-n16r8` | — / 562-715 | — / 8328KB-8333KB | — / 84KB-104KB | -| `pc-macos` | — / 26,316-333,333 | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-25 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -#### `measure-heavy-64` (measure) 📏 - -**Setup** (preceding non-measured steps): -- `grid-64-w` (set_control) -- `grid-64-h` (set_control) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 77.1-227 | — / 111KB-135KB | — / 88KB-108KB | -| `esp32p4-eth` | — / 411-491 | — / 33195KB-33210KB | — / 376KB | -| `esp32s3-n16r8` | — / 129-162 | — / 8302KB-8317KB | — / 92KB-108KB | -| `pc-macos` | — / 11,111-71,429 | — / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-17 → 2026-06-25 -- `esp32p4-eth`: observed 2026-06-17 → 2026-06-25 -- `esp32s3-n16r8`: observed 2026-06-17 → 2026-06-25 -- `pc-macos`: observed 2026-06-17 → 2026-07-01 - -## Layers - -### scenario_Layers_composition - -`test/scenarios/light/scenario_Layers_composition.json` — Multi-layer composition end-to-end: Layouts→Grid, TWO Layers under one Layers container (bottom Spiral, top Rainbow), Drivers→NetworkSendDriver. Proves the Drivers composite loop builds, allocates its output buffer, blends both enabled layers and feeds the result to the driver without crashing, and gates the bounded FPS so the N-pass composite cost is tracked. The exact alpha/additive blend math and the disable-drops-to-single-layer path are pinned by the unit tests (unit_BlendMap, unit_Layers_container); construct-mode set_control can't apply controls (built post-scheduler), so this scenario uses each Layer's default blend (alpha, full opacity) and asserts wired liveness + tick, not per-byte blend output. - -**Mode**: `construct` · **Also touches**: Layer, GridLayout, RainbowEffect, SpiralEffect, Drivers, NetworkSendDriver - -#### `add-artnet` (add_module) 📏 - -Add NetworkSendDriver and run the bounded FPS measurement over the two-layer composite (min_pct gates on hardware; skipped with a WARN in the desktop runner). - -**Setup** (preceding non-measured steps): -- `add-layout-group` (add_module) — Top-level Layouts container. -- `add-grid` (add_module) — 128x128 GridLayout under Layouts (above host clock resolution so the composite tick is measurable). -- `add-layers-group` (add_module) — Top-level Layers container — the multi-layer composition host. -- `add-bottom-layer` (add_module) — Bottom Layer (composited first — clears + overwrites the output buffer). RGB. -- `add-bottom-effect` (add_module) — A Spiral base as the bottom layer's effect. -- `add-top-layer` (add_module) — Top Layer (composited second — blends onto the bottom with its default blend). RGB. -- `add-top-effect` (add_module) — Rainbow as the top layer's effect — composited over the Spiral base. -- `add-driver-group` (add_module) — Top-level Drivers container wired to the Layers container (composites all enabled layers into its output buffer). - -**Bounds**: -- FPS ≥ 80% of baseline -- FPS × lights ≥ 294,912 - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | — / 1,149-19,231 | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-25 → 2026-07-01 - -## Layouts - -### scenario_Layouts_mutation - -`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, NetworkSendDriver - -#### `measure-one-layout` (measure) 📏 - -Baseline: a single 64x64 grid layout drives the pipeline. - -**Bounds**: -- FPS ≥ 1 (absolute) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32-eth` | — / 41,667 | — / 224KB | — / 108KB | -| `pc-macos` | — / 15,873-125,000 | — / unlimited | — / unlimited | -| `pc-windows` | — / 32,258-37,037 | — / unlimited | — / unlimited | - -- `esp32-eth`: observed 2026-06-08 -- `pc-macos`: observed 2026-06-05 → 2026-07-01 -- `pc-windows`: observed 2026-06-07 - -#### `measure-two-layouts` (measure) 📏 - -Pipeline still renders with two layouts wired (buffer non-null, fps measurable). - -**Setup** (preceding non-measured steps): -- `add-second-layout` (add_module) — Add a SECOND layout (a 64x64 grid) under Layouts — two layouts now live under one container. buildState re-runs; the pipeline must still render. (Both grids share the 0..63 coordinate box, so the Layer buffer stays 64x64 — see the scenario NOTE.) - -**Bounds**: -- FPS ≥ 1 (absolute) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32-eth` | — / 37,037 | — / 223KB | — / 108KB | -| `pc-macos` | — / 3,953-111,111 | — / unlimited | — / unlimited | -| `pc-windows` | — / 16,393-23,810 | — / unlimited | — / unlimited | - -- `esp32-eth`: observed 2026-06-08 -- `pc-macos`: observed 2026-06-05 → 2026-06-27 -- `pc-windows`: observed 2026-06-07 - -#### `measure-after-replace` (measure) 📏 - -Pipeline still renders after replacing a grid with a sphere (different layout type, same slot) — buffer re-wires without crashing. - -**Setup** (preceding non-measured steps): -- `replace-second-layout` (replace_module) — Replace the second grid with a SphereLayout (different type, same slot). The first grid is untouched; the pipeline re-wires to the new layout's light count. - -**Bounds**: -- FPS ≥ 1 (absolute) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32-eth` | — / 38,462 | — / 223KB | — / 108KB | -| `pc-macos` | — / 1,957-100,000 | — / unlimited | — / unlimited | -| `pc-windows` | — / 5,848-9,009 | — / unlimited | — / unlimited | - -- `esp32-eth`: observed 2026-06-08 -- `pc-macos`: observed 2026-06-05 → 2026-06-27 -- `pc-windows`: observed 2026-06-07 - -#### `measure-after-remove` (measure) 📏 - -Pipeline renders with the single remaining grid, same as the baseline. - -**Setup** (preceding non-measured steps): -- `remove-second-layout` (remove_module) — Remove the sphere — back to a single grid layout. Layer/Drivers shrink their buffers via buildState. - -**Bounds**: -- FPS ≥ 1 (absolute) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32-eth` | — / 41,667 | — / 224KB | — / 108KB | -| `pc-macos` | — / 6,623-125,000 | — / unlimited | — / unlimited | -| `pc-windows` | — / 33,333-38,462 | — / unlimited | — / unlimited | - -- `esp32-eth`: observed 2026-06-08 -- `pc-macos`: observed 2026-06-05 → 2026-07-01 -- `pc-windows`: observed 2026-06-07 - -## MoonLiveEffect - -### scenario_MoonLiveEffect_controls - -`test/scenarios/light/scenario_MoonLiveEffect_controls.json` — Exercise MoonLive Stage-1 CONTROLS end-to-end as a wired module. A script declares a control (`uint8_t speed = 7; // @control 0..15`) and uses it (`setRGB(speed, ...)`); the engine surfaces the control, the binding creates a real uint8 MoonModule control bound to the live control-values arena slot. The scenario: add the effect with a control script (the control appears, renders), change the CONTROL value live (a slider move — must NOT recompile; the arena byte updates and the next tick reads it), edit the SOURCE to add a second control (recompile re-derives the set, existing slider value preserved by the stable-address grow-only arena), edit the source to remove a control (the orphaned value drops), push a broken script (compile fails, renders dark, status shows the diagnostic, no crash), recover, and remove + re-add (resource teardown + re-acquire). A crash in the LoadCtrl codegen, a dangling arena pointer across a recompile, or a value change that wrongly triggers a recompile all show up as a failed measure or a tick spike. The codegen + live-read contract is pinned by unit_moonlive_ir / unit_moonlive_compiler; this is the wired-module gate. - -**Mode**: `mutate` · **Also touches**: Layouts, GridLayout, Layers, Layer, Drivers, NetworkSendDriver - -#### `add-control-script` (add_module) 📏 - -Add a MoonLiveEffect whose source declares a `speed` control and uses it. The control appears bound to the arena slot (seeded to its default 7); the wired effect renders one pixel. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | — / 250,000-— | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-28 → 2026-07-01 - -#### `set-source-with-control` (set_control) 📏 - -Edit the source to the control script. A source edit recompiles (controlChangeTriggersBuildState gates on `source`); the engine derives the `speed` control and the binding surfaces it. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | — / 1,000,000-— | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-28 → 2026-07-01 - -#### `change-control-live` (set_control) 📏 - -Change the `speed` control value (a slider move). This must NOT recompile — controlChangeTriggersBuildState returns false for a scripted control; the arena byte updates and the next render tick reads it. Tick stays cheap (a recompile would spike it). - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | — / 1,000,000-— | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-28 → 2026-07-01 - -#### `edit-source-two-controls` (set_control) 📏 - -Edit the source to add a second control. The recompile re-derives the control set; the stable-address grow-only arena keeps `speed`'s live value while seeding the new slot. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | — / 1,000,000-— | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-28 → 2026-07-01 - -#### `edit-source-shrink-to-one-control` (set_control) 📏 - -Edit the source back to a single control. The control set shrinks 2 -> 1: `speed` stays bound (its live value kept), the removed `hue`'s value is dropped, and the value change path is exercised without an unexpected recompile crash. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | — / 1,000,000-— | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-28 → 2026-07-01 - -#### `edit-source-broken` (set_control) 📏 - -Push a broken script. Compile fails, the previous code is freed, the effect renders dark and the parse error surfaces in status — no crash. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | — / 125,000-— | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-28 → 2026-07-01 - -#### `edit-source-recover` (set_control) 📏 - -Recover with a valid control script — the effect compiles and renders again. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | — / 1,000,000-— | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-28 → 2026-07-01 - -#### `re-add-control-effect` (add_module) 📏 - -Re-add a fresh effect after the remove — exec memory + control arena re-acquired clean (it renders its default fill on add). Control re-acquisition itself is proven by the add-control-script step at the top: a freshly-added effect compiling a control source surfaces + seeds its control; construct-mode set_control can't apply a dynamically-added scripted control as the final asserted render, so the gate here is the bare re-add's liveness. - -**Setup** (preceding non-measured steps): -- `remove-control-effect` (remove_module) — Remove the effect — the engine releases its exec block AND its control arena (teardown). - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | — / 500,000-— | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-28 → 2026-07-01 - -### scenario_MoonLiveEffect_livescript - -`test/scenarios/light/scenario_MoonLiveEffect_livescript.json` — Exercise a scripted MoonLiveEffect as a wired MoonModule end-to-end — the integration layer the unit tests can't reach. The effect compiles its `source` text to native code on-device and renders it into the Layer buffer each tick. Prepares its own canvas: Layout(Grid 16x16) + Layer + MoonLiveEffect, measures the default compile, then edits `source` live (a new fill colour recompiles and keeps rendering), pushes a BROKEN script (compile fails, the previous code is freed, the effect renders dark and the parse error surfaces in status, no crash), recovers with a valid script, and finally removes + re-adds the effect (add/remove robustness in any order). A crash in the JIT/emit path, a failed recompile that wedges the tick, or a buffer overrun on an odd grid all show up as a failed measure. The compiler + emit golden bytes are pinned by unit_moonlive_compiler / unit_moonlive_fill; this is the live wired-module gate. - -**Mode**: `mutate` · **Also touches**: Layouts, GridLayout, Layers, Layer, Drivers, NetworkSendDriver - -#### `add-moonlive` (add_module) 📏 - -Add a MoonLiveEffect to the Layer. Its default source (random pixels) compiles on-device to native code; measure that the wired effect renders. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32p4-eth` | — / 88.6 | — / 33211KB | — / 376KB | -| `esp32s3-n16r8` | — / 249 | — / 8341KB | — / 104KB | -| `pc-macos` | — / 2,545-— | — / unlimited | — / unlimited | - -- `esp32p4-eth`: observed 2026-06-27 -- `esp32s3-n16r8`: observed 2026-06-27 -- `pc-macos`: observed 2026-06-26 → 2026-06-27 - -#### `edit-source-red` (set_control) 📏 - -Live-edit the script source to a new colour. A source edit triggers a recompile (controlChangeTriggersBuildState gates on `source`); the new native code swaps in and keeps rendering. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32p4-eth` | — / 98.4 | — / 33213KB | — / 376KB | -| `esp32s3-n16r8` | — / 225 | — / 8341KB | — / 104KB | -| `pc-macos` | — / 2,513-— | — / unlimited | — / unlimited | - -- `esp32p4-eth`: observed 2026-06-27 -- `esp32s3-n16r8`: observed 2026-06-27 -- `pc-macos`: observed 2026-06-26 → 2026-06-27 - -#### `edit-source-broken` (set_control) 📏 - -Push a script that fails to parse. The compile fails, the engine reports the diagnostic in the module status and renders dark, but the device keeps running (robust, no reboot) — the script-editor failure path. The measure passes because the pipeline still ticks. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32p4-eth` | — / 94.6 | — / 33209KB | — / 376KB | -| `esp32s3-n16r8` | — / 229 | — / 8340KB | — / 104KB | -| `pc-macos` | — / 3,745-— | — / unlimited | — / unlimited | - -- `esp32p4-eth`: observed 2026-06-27 -- `esp32s3-n16r8`: observed 2026-06-27 -- `pc-macos`: observed 2026-06-26 → 2026-06-27 - -#### `edit-source-recover` (set_control) 📏 - -Push a valid script again. The engine recompiles cleanly and rendering resumes — a broken edit is fully recoverable. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32p4-eth` | — / 93.4 | — / 33212KB | — / 376KB | -| `esp32s3-n16r8` | — / 248 | — / 8340KB | — / 100KB | -| `pc-macos` | — / 2,415-— | — / unlimited | — / unlimited | - -- `esp32p4-eth`: observed 2026-06-27 -- `esp32s3-n16r8`: observed 2026-06-27 -- `pc-macos`: observed 2026-06-26 → 2026-06-27 - -#### `shrink-grid-1x1` (set_control) 📏 - -Resize the canvas to 1x1 while the scripted effect renders — the smallest non-empty grid. The native fill loops over a single light; the run guards (non-null buffer, cpl>=3) keep it in-bounds. Pins the 'runs at every grid size' hard rule for the JIT'd routine. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32p4-eth` | — / 862 | — / 33215KB | — / 376KB | -| `esp32s3-n16r8` | — / 868 | — / 8341KB | — / 100KB | -| `pc-macos` | — / 1,000,000-— | — / unlimited | — / unlimited | - -- `esp32p4-eth`: observed 2026-06-27 -- `esp32s3-n16r8`: observed 2026-06-27 -- `pc-macos`: observed 2026-06-26 → 2026-06-27 - -#### `grow-grid-back` (set_control) 📏 - -Resize back to a wider grid; the effect keeps rendering across the live dimension change (the no-reboot reconfiguration contract applied to scripted code). - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32p4-eth` | — / 97.0 | — / 33209KB | — / 376KB | -| `esp32s3-n16r8` | — / 136 | — / 8333KB | — / 100KB | -| `pc-macos` | — / 71,429-— | — / unlimited | — / unlimited | - -- `esp32p4-eth`: observed 2026-06-27 -- `esp32s3-n16r8`: observed 2026-06-27 -- `pc-macos`: observed 2026-06-26 → 2026-06-27 - -#### `remove-moonlive` (remove_module) 📏 - -Remove the scripted effect. teardown frees the exec block; the Layer keeps rendering (now empty). Measures add/remove robustness. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32p4-eth` | — / 88.2 | — / 33209KB | — / 376KB | -| `esp32s3-n16r8` | — / 135 | — / 8333KB | — / 100KB | -| `pc-macos` | — / 100,000-— | — / unlimited | — / unlimited | - -- `esp32p4-eth`: observed 2026-06-27 -- `esp32s3-n16r8`: observed 2026-06-27 -- `pc-macos`: observed 2026-06-26 → 2026-06-27 - -#### `re-add-moonlive` (add_module) 📏 - -Re-add a MoonLiveEffect after removal — the exec memory is re-acquired fresh, no leak, no stale pointer. The scripted effect renders again. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32p4-eth` | — / 90.9 | — / 33209KB | — / 376KB | -| `esp32s3-n16r8` | — / 121 | — / 8332KB | — / 100KB | -| `pc-macos` | — / 62,500-— | — / unlimited | — / unlimited | - -- `esp32p4-eth`: observed 2026-06-27 -- `esp32s3-n16r8`: observed 2026-06-27 -- `pc-macos`: observed 2026-06-26 → 2026-06-27 - -## MoonModule - -### scenario_MoonModule_control_change - -`test/scenarios/core/scenario_MoonModule_control_change.json` — Measure the cost of control changes on a running pipeline. Toggles MultiplyModifier's mirrorX/Y at different points and verifies each change is applied without freezing the render loop. Companion to the MoonModule control-change gate unit tests (unit_MoonModule_control_change_gate.cpp) — this is the live equivalent. - -**Mode**: `mutate` · **Also touches**: MultiplyModifier, NoiseEffect - -#### `baseline` (set_control) 📏 - -Set NoiseEffect.scale=4 and measure baseline FPS (mirror on). Effect controls don't rebuild the pipeline — slider stutter check. - -**Setup** (preceding non-measured steps): -- `canvas-clear-layers` (clear_children) — Self-canvas: clear+rebuild the pipeline this scenario assumes, so it runs from any device state (order-independent in a chained live run). Pre-wired apparatus survives clear_children; replaces the old fixture+reset model. -- `canvas-clear-layouts` (clear_children) -- `canvas-clear-drivers` (clear_children) -- `canvas-grid` (add_module) -- `canvas-layer` (add_module) -- `canvas-noise` (add_module) -- `canvas-mirror` (add_module) -- `canvas-artnet` (add_module) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 3.9 | — / 88KB | — / 48KB | -| `esp32-eth` | — / 10.5-10.6 | — / 133KB | — / 48KB-50KB | -| `esp32-eth-wifi` | ≥ 10.0 / 12.2 | ≥ 103KB / 94KB | — / 48KB | -| `esp32p4-eth` | — / 4,926-6,250 | — / 33238KB | — / 376KB | -| `pc-macos` | ≥ 8,333 / 3,165-10,309 | unlimited / unlimited | — / unlimited | -| `pc-windows` | — / 4,000-4,405 | — / 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 -- `esp32p4-eth`: observed 2026-06-17 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-25 -- `pc-windows`: observed 2026-06-07 - -#### `disable-mirrorX` (set_control) 📏 - -Disable mirrorX. Modifier control triggers a pipeline rebuild — measures the rebuilt path. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 4.8 | — / 88KB | — / 48KB | -| `esp32-eth` | — / 10.4 | — / 132KB | — / 48KB-50KB | -| `esp32-eth-wifi` | ≥ 10.0 / 12.0 | ≥ 103KB / 94KB | — / 48KB | -| `esp32p4-eth` | — / 5,952-6,135 | — / 33238KB | — / 376KB | -| `pc-macos` | ≥ 5,000 / 2,857-9,259 | unlimited / unlimited | — / unlimited | -| `pc-windows` | — / 2,024-2,392 | — / 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 -- `esp32p4-eth`: observed 2026-06-17 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-07-01 -- `pc-windows`: observed 2026-06-07 - -#### `disable-mirrorY` (set_control) 📏 - -Disable mirrorY. Mirror is now fully off — should land on the no-LUT path. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 4.4 | — / 88KB | — / 48KB | -| `esp32-eth` | — / 8.9-9.0 | — / 132KB | — / 48KB-50KB | -| `esp32-eth-wifi` | ≥ 10.0 / 11.1 | ≥ 103KB / 94KB | — / 48KB | -| `esp32p4-eth` | — / 5,587-6,061 | — / 33238KB | — / 376KB | -| `pc-macos` | ≥ 2,500 / 1,916-9,346 | 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 -- `esp32p4-eth`: observed 2026-06-17 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-15 -- `pc-windows`: observed 2026-06-07 - -#### `re-enable-mirrorY` (set_control) 📏 - -Re-enable mirrorY and measure — the heavy LUT path must recover (FPS within 50% of baseline) without staying degraded. - -**Setup** (preceding non-measured steps): -- `re-enable-mirrors` (set_control) — Re-enable mirrorX (rebuild back to LUT path). - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 4.4 | — / 88KB | — / 48KB | -| `esp32-eth` | — / 10.5-10.6 | — / 132KB | — / 48KB-50KB | -| `esp32-eth-wifi` | ≥ 10.0 / 12.1 | ≥ 103KB / 94KB | — / 48KB | -| `esp32p4-eth` | — / 5,319-6,098 | — / 33238KB | — / 376KB | -| `pc-macos` | ≥ 8,333 / 3,356-10,417 | unlimited / unlimited | — / unlimited | -| `pc-windows` | — / 4,065-4,854 | — / 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 -- `esp32p4-eth`: observed 2026-06-17 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-30 -- `pc-windows`: observed 2026-06-07 - -## MultiplyModifier - -### scenario_MultiplyModifier_memory_lut - -`test/scenarios/light/scenario_MultiplyModifier_memory_lut.json` — Verify that adding a MultiplyModifier allocates both the mapping LUT and the driver buffer (the heavy memory path). Companion to scenario_Layer_memory_1to1, which verifies the no-LUT path. - -**Mode**: `construct` · **Also touches**: Layer, MappingLUT, BlendMap - -#### `add-artnet` (add_module) 📏 - -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. -- `add-grid` (add_module) — Add a 16x16 GridLayout. -- `add-layer` (add_module) — Add a Layer wired to Layouts (RGB). -- `add-noise` (add_module) — Add NoiseEffect as the Layer's effect. -- `add-mirror` (add_module) — Add MultiplyModifier — triggers LUT and driver-buffer allocation. -- `add-driver-group` (add_module) — Add a Drivers container wired to the Layer. - -**Bounds**: -- FPS ≥ 80% of baseline - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | ≥ 8,333 / 3,322-1,000,000 | unlimited / unlimited | — / unlimited | -| `pc-windows` | — / 166,667-333,333 | — / unlimited | — / unlimited | - -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 -- `pc-windows`: observed 2026-06-07 - -### scenario_MultiplyModifier_pipeline - -`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, NetworkSendDriver - -#### `add-artnet` (add_module) 📏 - -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. -- `add-grid` (add_module) — Add a 128x128 GridLayout child to Layouts. Set explicitly (the module default is 16x16x1) so the tick is measurable above the host's microsecond clock. -- `add-layer` (add_module) — Add a Layer wired to Layouts (RGB). -- `add-noise` (add_module) — Add NoiseEffect as the Layer's effect. -- `add-mirror` (add_module) — Add MultiplyModifier so logical pixels reflect across X and Y in the physical grid. -- `add-driver-group` (add_module) — Add a Drivers container wired to the Layer's output buffer. - -**Bounds**: -- FPS ≥ 80% of baseline - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | ≥ 8,333 / 3,676-1,000,000 | unlimited / unlimited | — / unlimited | -| `pc-windows` | — / 3,953-4,444 | — / unlimited | — / unlimited | - -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-25 -- `pc-windows`: observed 2026-06-07 - -## NetworkModule - -### scenario_NetworkModule_eth_reconfigure - -`test/scenarios/core/scenario_NetworkModule_eth_reconfigure.json` — Cycle the Ethernet PHY type (ethType: None/LAN8720/IP101/W5500) live on a running device and confirm the render loop survives every transition. Pins the robustness principle for the runtime Ethernet config: changing ethType reshapes the platform eth config (NetworkModule.syncEthLive → setEthConfig, with a live ethStop+ethInit on W5500), and no value or transition order may crash or wedge the tick. Runs live only — the eth controls exist only on hasEthernet ESP32 builds, so the in-process desktop runner SKIPs it; on a device it drives the controls over HTTP. On RMII boards (Olimex) the change saves + asks for restart (no hot re-init), so the live HTTP connection is undisturbed; the W5500 path hot-reinits but the SPI bus teardown keeps the netif alive enough to keep serving. - -**Mode**: `mutate` · **live-only** (skipped in-process) - -#### `ethType-lan8720` (set_control) 📏 - -LAN8720 (RMII) — the classic-ESP32 default. Baseline: device renders with Ethernet RMII selected. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 133-146 | — / 165KB | — / 108KB | - -- `esp32`: observed 2026-06-15 - -#### `ethType-none` (set_control) 📏 - -Switch to None (no Ethernet) live. The eth pin rows hide; the device must keep rendering and stay reachable. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 145-147 | — / 165KB | — / 108KB | - -- `esp32`: observed 2026-06-15 - -#### `ethType-w5500` (set_control) 📏 - -Switch to W5500 (SPI) live. On an S3 this hot-reinits eth (ethStop + ethInit); on RMII boards it saves + flags restart. Either way the render loop must survive. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 133-135 | — / 165KB | — / 108KB | - -- `esp32`: observed 2026-06-15 - -#### `ethType-ip101` (set_control) 📏 - -Switch to IP101 (RMII) live — the P4 PHY. Exercises the last dropdown value; render loop must survive. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 146-147 | — / 165KB | — / 108KB | - -- `esp32`: observed 2026-06-15 - -#### `ethType-back-to-lan8720` (set_control) 📏 - -Return to LAN8720 — confirms the cycle is reversible and leaves the device in a sane state. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 134-145 | — / 165KB | — / 108KB | - -- `esp32`: observed 2026-06-15 - -### scenario_NetworkModule_mdns_toggle - -`test/scenarios/core/scenario_NetworkModule_mdns_toggle.json` — Toggle the mDNS responder on and off and measure render-FPS impact. Validates that mDNS announcement traffic doesn't degrade the render loop more than 20% on the busiest tick. - -**Mode**: `mutate` · **live-only** (skipped in-process) - -#### `baseline-mdns-on` (set_control) 📏 - -mDNS on (default) — captures the baseline FPS for the next two steps. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 4.4 | — / 88KB | — / 48KB | -| `esp32-eth` | — / 10.5-10.6 | — / 132KB | — / 48KB-50KB | -| `esp32-eth-wifi` | ≥ 10.0 / 12.2 | ≥ 103KB / 93KB | — / 48KB | -| `esp32p4-eth` | — / 47,619 | — / 33245KB | — / 376KB | - -- `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 -- `esp32p4-eth`: observed 2026-06-17 - -#### `mdns-off` (set_control) 📏 - -mDNS off — measured. Expected to match or exceed the baseline. - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 3.6 | — / 88KB | — / 48KB | -| `esp32-eth` | — / 10.3-10.5 | — / 137KB | — / 48KB-52KB | -| `esp32-eth-wifi` | ≥ 10.0 / 12.0 | ≥ 93KB / 98KB | — / 48KB | -| `esp32p4-eth` | — / 47,619 | — / 33245KB | — / 376KB | - -- `esp32`: observed 2026-06-02 -- `esp32-eth`: observed 2026-06-02 -- `esp32-eth-wifi`: contract set 2026-06-02 "shared heap budget; cumulative sweep state reduces standalone-mDNS-off heap by ~15KB" · observed 2026-06-02 -- `esp32p4-eth`: observed 2026-06-17 - -#### `mdns-on-again` (set_control) 📏 - -mDNS on again — measured with a bound: FPS must stay within 20% of the baseline (proves toggling doesn't leave the network task in a degraded state). - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 4.3 | — / 83KB | — / 48KB | -| `esp32-eth` | — / 9.1 | — / 132KB | — / 48KB-52KB | -| `esp32-eth-wifi` | ≥ 10.0 / 10.6 | ≥ 103KB / 93KB | — / 48KB | -| `esp32p4-eth` | — / 45,455-50,000 | — / 33245KB | — / 376KB | - -- `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 -- `esp32p4-eth`: observed 2026-06-17 - -## NetworkSendDriver - -### scenario_Driver_mutation - -`test/scenarios/light/scenario_Driver_mutation.json` — Add / remove output drivers under the Drivers container while the render pipeline runs, proving the robustness rule for driver deletion (the LED-driver delete path the product owner asked to cover; on the host RmtLed/LcdLed/Parlio are inert via platform constants, so the portable NetworkSendDriver + PreviewDriver exercise the SAME generic add/remove lifecycle through the Scheduler that the hardware drivers use). Drivers are consumers of the Layer's output buffer, added/removed at runtime in any order. The checks assert the pipeline keeps RENDERING (buffer non-null, fps measurable) through each mutation: adding a second driver, removing it, and crucially removing a driver while the pipeline is still live (a driver teardown must release its resources without stranding the buffer or the other drivers — the same end-to-end Scheduler path the Audio producer/consumer scenario proves for peripherals, here for the output stage). Grid is 64x64 so the tick stays above the host microsecond clock at every step. - -**Mode**: `mutate` · **Also touches**: GridLayout, Layer, RainbowEffect, Drivers, PreviewDriver - -#### `measure-one-driver` (measure) 📏 - -Baseline: the pipeline renders with one driver (Preview) wired. - -**Bounds**: -- FPS ≥ 1 (absolute) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | — / 13,699-125,000 | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-13 → 2026-06-30 - -#### `measure-two-drivers` (measure) 📏 - -Pipeline renders with both drivers wired. - -**Setup** (preceding non-measured steps): -- `add-second-driver` (add_module) — Add a NetworkSendDriver beside the Preview driver — two consumers of the same Layer output buffer. The add must not disturb the running pipeline. - -**Bounds**: -- FPS ≥ 1 (absolute) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | — / 10,204-125,000 | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-13 → 2026-07-01 - -#### `measure-three-drivers` (measure) 📏 - -Pipeline renders with three drivers wired. - -**Setup** (preceding non-measured steps): -- `add-third-driver` (add_module) — Add a second NetworkSendDriver — three drivers now share the Layer output buffer (Preview + two ArtNet). Stacking editable drivers before tearing them down in sequence. - -**Bounds**: -- FPS ≥ 1 (absolute) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | — / 13,333-125,000 | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-13 → 2026-07-01 - -#### `measure-after-first-remove` (measure) 📏 - -One ArtNet gone, Preview + ArtNet2 remain: pipeline keeps rendering (buffer non-null, fps measurable). No crash from the mid-list teardown. - -**Setup** (preceding non-measured steps): -- `remove-first-added-driver` (remove_module) — Remove the FIRST-added NetworkSendDriver while the others (Preview + ArtNet2) are still live — delete a middle consumer, not the last. Its teardown must release its resources without stranding the buffer or the surviving drivers. - -**Bounds**: -- FPS ≥ 1 (absolute) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | — / 15,152-125,000 | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-13 → 2026-07-01 - -#### `measure-back-to-one-driver` (measure) 📏 - -Both added drivers gone, back to the single Preview baseline, still rendering — the add/remove cycle leaves the pipeline coherent. - -**Setup** (preceding non-measured steps): -- `remove-second-added-driver` (remove_module) — Remove the remaining editable driver (ArtNet2) too — back to just the boot-wired Preview. Repeated driver teardown leaves no residue; the pipeline still renders. (PreviewDriver is userEditable=false, so it stays — drivers always have at least the wired output consumer; this mirrors the live API, which forbids deleting code-wired modules.) - -**Bounds**: -- FPS ≥ 1 (absolute) - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | — / 15,873-125,000 | — / unlimited | — / unlimited | - -- `pc-macos`: observed 2026-06-13 → 2026-07-01 diff --git a/docs/tests/unit-tests.md b/docs/tests/unit-tests.md deleted file mode 100644 index 1a350af4..00000000 --- a/docs/tests/unit-tests.md +++ /dev/null @@ -1,1000 +0,0 @@ -# Unit Tests - -Auto-generated from `test/unit/{core,light}/unit_*.cpp` by `scripts/docs/generate_test_docs.py`. **Do not edit by hand** — update the source file's `@module` / `@also` and per-TEST_CASE `//` descriptions instead, then regenerate. - -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. - -## 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: two mics — first wins, survivor re-elects, any order stays coherent_ - -## BlendMap - -`test/unit/light/unit_BlendMap.cpp` -*Also touches: MappingLUT.* - -- Identity mapping (logical N → physical N) leaves every byte unchanged. -- One logical light routed to multiple physical positions copies the colour to each (mirror-style mappings work). -- A paged LUT (forced via the maxAllocBlock test cap) must produce a byte-identical dst to a single-alloc LUT with the same mapping. Paging is an allocation detail; blendMap output must not depend on it. This is the end-to-end pin for the no-PSRAM-fragmentation fix. -- An additive (overwrites=false) LUT folding two sources onto one physical light adds and clamps at 255 (no overflow). overwrites=false is the opt-in for the within-layer overlap case; the default copy path would instead overwrite, and a full-opacity Overwrite op still routes through this additive accumulate, so this pins the contract explicitly (the regression after the multi-layer rewrite). -- The default (overwrites=true) path plain-copies: two sources mapped to the same physical means the LAST writer wins, no addition. Pins the fast path. -- Sparse overwrite mapping clears untouched physical cells. A sphere-style layout maps only a subset of the physical box to a source; the rest must end up black, not retain stale data from a previous frame. Pre-fills dst dirty and asserts unmapped cells are zeroed — fails if BlendMap's dst.clear() is removed (the regression target). -- Alpha-over at half opacity: dst = src*α + dst*(255-α). With dst=200, src=100, α=128 → 100*128 + 200*127 = 12800 + 25400 = 38200; /255 ≈ 150. -- Alpha at full opacity collapses to overwrite (src replaces dst exactly). -- Alpha at opacity 0 is a no-op (dst unchanged) — the invisible-layer case. -- Additive with opacity scales the source before adding, then clamps. dst=100, src=200, opacity=128 → add 200*128/255 ≈ 100 → 200. -- clearFirst=false preserves dst cells the source doesn't touch — the mechanic that lets a top layer blend onto the bottom layer's already-composited frame. -- No-LUT alpha-over at half opacity: dst = div255(src*α + dst*(255-α)). dst=200, src=100, α=128 → div255(100*128 + 200*127) = div255(38200) = 149. -- No-LUT alpha at full opacity collapses to a plain copy (overwrite). -- No-LUT alpha at opacity 0 is a no-op (the invisible top layer). -- No-LUT additive with opacity scales the source then clamps at 255. dst=100, src=200, opacity=128 → 100 + div255(200*128)=100 → 200. -- No-LUT additive at full opacity saturates: 200 + 100 = 300 → clamp 255. - -## Buffer - -`test/unit/core/unit_Buffer.cpp` - -- allocate(N,3) reserves count×channels bytes; count/channelsPerLight/bytes/data/span all reflect that. -- clear() zeroes every byte in the allocated range. -- Move-constructing transfers the data pointer and resets the source (no double free, no copy). -- Move-assigning transfers ownership the same way the move constructor does. -- Calling free() twice is harmless; pointer and count remain zeroed. -- allocate() refuses zero-count or zero-channels (returns false, no allocation, buffer left empty so a caller that ignores the bool doesn't get a partial state). - -## CheckerboardModifier - -`test/unit/light/unit_CheckerboardModifier.cpp` - -- A mask leaves the logical box unchanged. -- size=1: every cell is its own square; parity = (x+y+z)&1. Default (invert false) keeps even-parity cells, drops odd-parity. Passing cells keep their coord. -- invert flips which parity passes. -- size>1 groups cells into squares: with size=2, the 2×2 block at the origin is all one square (parity 0), so all four pass; the next block over drops. - -## Color - -`test/unit/core/unit_Color.cpp` - -- Hue 0 is pure red. -- Hue 85 (one third round the wheel) is pure green; a sliver of red is tolerated since 85 is approximate, not exact. -- Hue 170 (two thirds round) is pure blue. -- Zero saturation produces a grey of the given value, regardless of hue. -- Zero value is black, regardless of hue or saturation. -- A hue between the cardinal points blends two channels (here: orange = red + green). -- `hsvToRgb` is `constexpr` — evaluable at compile time. -- `scale8(v, f)` multiplies two 8-bit values and returns 8 bits. Factor 255 is identity, factor 0 zeroes, factor 128 halves (within integer rounding). -- `scale8` is also `constexpr`. - -## Control - -`test/unit/core/unit_Control_apply_absent_key.cpp` -*Also touches: FilesystemModule.* - -- hasKey distinguishes an absent key from one whose value is 0 — the capability the fix relies on. parseInt alone can't (returns 0 for both). -- The core regression: a control bound with a non-zero value, overlaid with a JSON that does NOT contain its key, must keep its value — not snap to 0. -- A present key still applies (the fix must not break the normal load path). -- A present key whose value IS 0 must apply the 0 (don't confuse "present 0" with "absent"). Guards against an over-eager fix that skipped on value rather than key. -- _a per-control validator accepts a valid value and rejects bad input_ -- Length boundary of the deviceModel validator (accepts 1..31). Uses a buffer wider than the validator's limit so the 32-char value reaches the validator intact (parseString truncates to bufSize-1, so the buffer must exceed 32 for the validator's own length check — not parse truncation — to be what rejects it). The scratch buffer in applyControlValue is sized to bufSize, so a long value isn't truncated before validation. -- _a Text control with no validator accepts anything that fits_ - -`test/unit/core/unit_Control_list.cpp` - -- _ControlType::List value serializes as an array of row summaries_ -- _ControlType::List metadata carries a parallel detail array_ -- _ControlType::List with an empty source emits []_ -- _ControlType::List type identity + persistable + restore round-trip_ - -## Correction - -`test/unit/light/unit_Correction.cpp` - -- At brightness=255, the LUT maps every input value to itself (no scaling). -- At brightness=128, every entry is roughly halved using scale8 (255→128, 128→64, 2→1). -- RGB preset at full brightness passes the source RGB through unchanged (3 output channels, no white). -- GRB preset swaps R and G in the output (G first, then R, then B) — for WS2812-like drivers. -- BGR preset reverses the channel order entirely (B, G, R). -- RGBW preset adds a fourth white channel derived as min(R, G, B) per pixel. -- GRBW preset combines the GRB reorder with the W derivation (G, R, B, W=min). -- Brightness scaling runs before white derivation so W = min of the *scaled* RGB values. -- rebuild() can switch the output channel count between RGB (3) and RGBW (4) on the fly. - -## DemoReelEffect - -`test/unit/light/unit_DemoReelEffect.cpp` - -- The reel enumerates the effect registry, hosts one effect at a time, renders it, and advances through the whole list without crashing — the create/teardown/delete churn every tick is the robustness path this pins. Registering two real effects + the reel gives it something to cycle. - -## DevicePlugin - -`test/unit/core/unit_DeviceIdentify.cpp` -*Also touches: DevicesModule.* - -- _MmPlugin claims a presence packet carrying the projectMM marker_ -- _MmPlugin declines a plain WLED packet (no projectMM marker)_ -- _WledPlugin claims a plain WLED packet as WLED_ -- _WledPlugin declines a projectMM-marked packet (that's a peer, not a WLED)_ -- _Plugins decline a short / garbage datagram, never read out of bounds_ -- _WledPlugin tolerates an empty name (the module supplies the IP fallback)_ - -## DevicesModule - -`test/unit/core/unit_DevicesModule_ageout.cpp` - -- A cached (restored-but-never-re-heard) device is on a short probation, NOT the full 24 h — else a long-gone persisted device would survive forever across reboots (its clock resets to "boot" each restore). It drops once past kCachedGraceMs. -- _DevicesModule: a cached device drops once past the probation window_ -- A live-confirmed device (a presence packet cleared its `cached` flag) gets the full 24 h. -- _DevicesModule: a live-confirmed device drops once past kStaleMs (24h)_ -- A projectMM peer also answers as a plain WLED (its presence packet without our marker), so a later WLED-classified sighting must NOT relabel a restored projectMM row. This drives the downgrade-prevention in upsertDevice through the public path: restore the row as projectMM, inject a plain WLED packet from the same IP, confirm it stays projectMM. -- _DevicesModule: restore tolerates an empty / malformed cache_ - -`test/unit/core/unit_DevicesModule_discovery.cpp` -*Also touches: DevicePlugin.* - -- _DevicesModule: a plain WLED packet lists a WLED device with its name_ -- _DevicesModule: a projectMM-marked packet lists a projectMM device_ -- _DevicesModule: a short / garbage datagram is ignored, never listed_ -- The P4-bench bug: two DIFFERENT devices (a WLED and a projectMM peer) must each keep their OWN name + type — no cross-contamination between packets. -- A peer RENAME must propagate: a later packet from the same IP with a new name updates the row in place — the live-update requirement (the name rides the presence packet). -- A projectMM device stays projectMM even when a later plain-WLED packet arrives from the same address — the type only RAISES toward projectMM, never downgrades. (A projectMM peer could be seen via an unmarked packet too; that must not relabel it WLED.) - -`test/unit/core/unit_DevicesModule_hue.cpp` - -- _DevicesModule: a Hue bridge is listed with its colour count_ -- _DevicesModule: upsertHueBridge is idempotent, updates count in place_ -- _DevicesModule: a persisted Hue bridge restores as a Hue row with its count_ -- _DevicesModule: a corrupt persisted colour clamps to the valid range, row still restores_ - -## DistortionWavesEffect - -`test/unit/light/unit_DistortionWavesEffect.cpp` - -- _DistortionWavesEffect writes non-zero RGB data_ -- _DistortionWavesEffect produces spatial variation_ -- _DistortionWavesEffect speed 0 is frozen (stable across ticks)_ -- _DistortionWavesEffect survives a 0x0x0 grid_ - -## Drivers - -`test/unit/light/unit_Drivers_container.cpp` - -- Disabled child drivers don't tick: toggling `enabled` flips whether that driver's loop() runs. - -`test/unit/light/unit_Drivers_firstOutputRgb.cpp` - -- _Drivers::firstOutputRgb reads pixel 0 of the driven buffer_ -- _Drivers::firstOutputRgb reports black pixel 0 as-is (caller substitutes the default)_ -- _Drivers::firstOutputRgb returns false when there is no driven buffer_ -- _MoonModule::firstOutputRgb defaults to false (no output module)_ - -## FilesystemModule - -`test/unit/core/unit_FilesystemModule_persistence.cpp` -*Also touches: Scheduler, Layer.* - -- Persistence round-trip: set deviceName → save → recreate Scheduler+modules → load → assert. Uses fsSetRoot to isolate the test from any real /.config/ on disk. A control change (deviceName) saved with flush() reappears on the next boot once a fresh Scheduler loads the same path. -- Structural persistence: hand-write a Layer.json describing a different tree shape than the one main.cpp builds, then load and verify the live tree reconciles to match the JSON — type swap at position 0, trim of position 1. On load, a Layer's children are reconciled against the saved JSON: position 0 swaps to the saved type, extras at later positions are trimmed. -- Pins the wiredByCode-preserves-child contract that lets a new firmware revision add a code-created child (e.g. ImprovProvisioning under NetworkModule) without the child getting trimmed on every boot for users whose saved Network.json predates the addition. Setup: an on-disk file describes Layer with zero children. Live tree has Layer with a RainbowEffect child that main.cpp would have wired and marked. After scheduler.setup() runs the persistence load, the wired child must survive. A code-wired child (markWiredByCode) survives a load from older JSON that doesn't mention it — new firmware additions aren't trimmed for existing users. -- Companion to the wiredByCode case above: when the JSON describes a different type at the position where a code-wired child lives, the position-replacement must NOT kill the code-wired child. Stop reconciliation at that index instead and let the next save re-write the file with the actual tree shape. When the saved JSON wants a different type at the position where a code-wired child lives, reconciliation stops at that index instead of destroying the wired child. -- Round-trip persistence with children: write a Layer subtree that contains both controls and child modules with controls of their own, then read the file back as text and verify it parses as valid JSON. Regresses the missing-comma bug between each child's "N.type" field and that child's first control (e.g. "0.type":"X""0.foo":1 instead of "0.type":"X","0.foo":1). Saving a Layer with multiple children produces valid JSON — comma separators between child `N.type` and the child's first control field are present. -- Singleton survives probe lifecycle: /api/types factory-creates a probe of every registered type (including FilesystemModule) to capture defaults, then deletes it. The probe's destructor must NOT clear the singleton — otherwise every save path (noteDirty, debounced loop1s, flushPending on reboot) silently no-ops for the rest of the device's life. The fix is to register the singleton in setScheduler(), not in the constructor. This test catches that singleton-clear regression. /api/types factory-creates a temporary FilesystemModule probe; its destruction must NOT clear the static singleton (otherwise every later save silently no-ops). -- Regression: Int16 controls (GridLayout's width/height/depth, Layer's start/end) round-tripped through the filesystem load path were clamped to c.min/c.max, which default to 0,0 because ControlDescriptor.min/max are uint8_t and can't represent an int16 range. Every Int16 control loaded as 0 — so a 128×128 grid became 0×0×0 after restart and the whole pipeline allocated no buffers. Int16 controls (GridLayout width/height, RegionModifier start/end) preserve their saved value across load — no zero-clamping from uint8 min/max bounds. - -## FireEffect - -`test/unit/light/unit_FireEffect.cpp` - -- On a 16×16 grid the heat buffer sizes to width × height bytes (one byte of heat per cell). -- With sparking at max, the buffer contains non-zero pixels within 50 frames (sparks emerge and propagate). -- Disabling the effect releases its heat buffer back (dynamicBytes drops to 0). - -## FirmwareUpdateModule - -`test/unit/core/unit_FirmwareUpdateModule.cpp` - -- The `firmware` control is always present and non-empty (either a real firmware key from build_info.h or the fallback "unknown"). The firmware card owns firmware identity (version/build/firmware) + the partition usage. -- OTA phase is surfaced through the shared status slot (MoonModule::setStatus()), not a control. publishStatus() runs in setup()/loop1s() and maps the platform OTA status string to a severity: "idle" clears the banner, an "error: " prefix is Severity::Error, anything else is neutral Severity::Status. - -## GameOfLifeEffect - -`test/unit/light/unit_GameOfLifeEffect.cpp` - -- The B#/S# parser turns a rule string into birth/survive neighbour sets. Conway = B3/S23. -- A 2×2 block is a Conway still life: every live cell has 3 neighbours (survives), and the surrounding dead cells never have exactly 3 (no births). It must be identical after a step. -- Regression: a 3D grid gives a cell up to 26 neighbours (3×3×3 minus self), but the B/S rule tables are sized 9 (single-digit Conway notation, 0..8). A dense 3D neighbourhood must not read those tables out of bounds — a count ≥9 is in no single-digit ruleset, so the cell dies / stays dead. This fills a 3×3×3 cube (the centre has all 26 neighbours alive) and just steps: the test passing under ASan/bounds-checking is the OOB-read pin; behaviourally the over-crowded centre dies (26 ∉ S) and the dense interior doesn't survive. -- A horizontal 3-cell blinker oscillates to vertical after one step (period-2 oscillator). This is the canonical "the rules actually run" check: birth on 3, death of the ends (1 neighbour each). -- A lone cell (0 neighbours) dies — the dead-by-isolation rule, and a sanity check that an empty grid stays empty (no spontaneous births at count 0 under Conway). - -## GridLayout - -`test/unit/light/unit_GridLayout.cpp` -*Also touches: Layouts.* - -- A 4×4×1 grid yields 16 lights iterated row-major: x sweeps fastest, then y, then z. -- Serpentine reverses x on odd rows (boustrophedon), so the strip snakes back and forth: driver index advances linearly while the emitted x zigzags. Even rows L→R, odd rows R→L. The COORDINATE is always the true (x,y) — only the index→position order changes, which is what makes the mapping non-identity. -- A 3D 2×2×2 grid yields 8 lights with z-plane separation (indices 0-3 at z=0, 4-7 at z=1). -- A single-light grid (1×1×1) is a valid layout: one coordinate at (0,0,0). -- Layouts with a single child delegates totalLightCount and forEachCoord to that child directly. -- Two child layouts produce contiguous physical indices: the second layout's coords are offset by the first's lightCount. - -## HttpServerModule - -`test/unit/core/unit_HttpServerModule_apply.cpp` - -- _apply-core: applyAddModule adds a child, idempotent on the id_ -- _apply-core: applySetControl writes a value, rejects out-of-range / unknown_ -- _apply-core: applyClearChildren empties a container (replaceChildren)_ -- _apply-core: applyOp dispatches each op type and tolerates bad input_ -- A per-control validator (like SystemModule.deviceModel's printable-ASCII rule) is enforced THROUGH the apply-core — so the APPLY_OP `set` the installer pushes over serial is guarded exactly like an HTTP write, with no per-transport special-casing. This is the point of moving validation onto the control: one backend check, every path. - -## HueDriver - -`test/unit/light/unit_HueDriver.cpp` - -- _HueDriver: a coloured pixel becomes an on/bri/hue/sat state body_ -- _HueDriver: a black pixel becomes on:false_ -- _HueDriver: RGB→HSV maps the primaries to the right Hue wheel positions_ -- _HueDriver: unchanged colour is not resent, a changed one is_ -- _HueDriver: parseLights keeps only colour-capable, reachable lights_ -- Room + light selection filters which colour lights the driver actually drives. Both dropdowns default to "All" (index 0): then every colour light is driven (unchanged behaviour). Selecting a room narrows the driven set to that room's colour lights; selecting a light drives just that one. -- The single status line (folding what were the separate hueStatus / colourLights controls) shows the light count as driven-of-total: "N-M lights" while filtered, the plain "M lights" when not. -- fetchLights sizes its read buffer by growing while the body looks truncated. The signal is "does the body end in '}'": a too-small buffer cuts the JSON mid-content. (Regression: an earlier check tested strlen==cap-1, which never fires because httpRequest strips headers first, so a >2 KB bridge response was parsed truncated and lights silently disappeared.) - -## ImprovFrame - -`test/unit/core/unit_ImprovFrame.cpp` - -- improvChecksum returns the sum of all input bytes modulo 256 (zero-length input is 0). -- buildImprovFrame writes the full wire shape: "IMPROV" magic + version + type + length + payload + 1-byte checksum. -- A payload larger than kImprovMaxPayload (128) is refused: builder returns 0 bytes written. -- If the caller's output buffer can't hold the framed bytes, the builder refuses (returns 0). -- A zero-length payload is valid: length byte is 0, checksum covers magic+version+type+length only. -- Feeding a well-formed frame byte by byte ends in FrameReady; the parser exposes the type, length, and payload. -- A zero-length payload frame parses to FrameReady with lastPayloadLen() == 0. -- A corrupted checksum byte yields BadChecksum at the end of the frame. -- A length byte greater than kImprovMaxPayload trips OversizePayload at that byte (before any payload data arrives). -- Garbage bytes before the magic 'I' are silently skipped; a fresh well-formed frame after them parses normally. -- "I" followed by another "I" treats the second byte as a fresh magic-start (not discarded) — the parser doesn't lose a real frame that begins mid-aborted-magic. -- When the byte after MagicV isn't the version but happens to be 'I', the parser re-enters magic search at Magic1 — recovers a new frame that arrives right after a corrupted header. -- Every defined ImprovFrameType (CurrentState, ErrorState, Rpc, RpcResponse) round-trips through builder + parser cleanly. -- After FrameReady the parser returns to Magic0 and parses the next frame on the same instance without reset. - -## ImprovOpReassembler - -`test/unit/core/unit_ImprovOpReassembler.cpp` - -- _a single-frame op (seq 0, last 1) is Ready with the exact bytes_ -- _a multi-chunk op reassembles in order and NUL-terminates_ -- _a duplicate chunk is rejected and resets the buffer_ -- _an out-of-order chunk (skipped seq) is rejected_ -- _a non-zero opening seq (no fresh start) is rejected_ -- _overflow past the buffer (minus the NUL) is rejected, not truncated_ -- _exactly buffer-minus-one bytes fits (boundary)_ -- _seq 0 mid-stream abandons a partial op and starts fresh_ -- _an empty final chunk still completes (last with zero bytes)_ -- _reset() drops a partial op_ - -## JsonUtil - -`test/unit/core/unit_JsonUtil_parse.cpp` - -- _parse a flat object reads each typed field_ -- _parse an array of objects (the persisted device list use case)_ -- _parse a nested object_ -- _escaped quotes and backslashes round-trip inside a string value_ -- _negative and fractional numbers_ -- _malformed inputs fail cleanly without crashing_ -- _overflow safety: too many nodes fails cleanly_ -- _overflow safety: nesting deeper than kMaxDepth fails cleanly_ -- _input longer than the text buffer fails cleanly_ -- parseString must DECODE the JSON string escapes our own writer emits (JsonSink/writeJsonString) — \" \\ \n \r \t \b \f — so reader and writer are symmetric. A multi-line value (a script with `\n`) must arrive as a real newline, not a literal backslash-n. - -## Layer - -`test/unit/light/unit_Layer_extrude.cpp` -*Also touches: RainbowEffect, NoiseEffect, PlasmaEffect, SpiralEffect, FireEffect, ParticlesEffect.* - -- A D2 effect (Rainbow) on a 3D layer writes z=0 once; Layer::extrude copies that slice across every z>0 — slices are byte-identical. -- A D1 effect writes the x=0 column; extrude duplicates it across every x and every z-slice. -- NoiseEffect declared D3 still produces a valid image on a depth=1 layer (it honours the runtime depth instead of hardcoding z). -- PlasmaEffect (D3) on a 2D layer same contract: valid 2D image, no buffer overrun. -- NoiseEffect (D3) on a 1D layer (height=depth=1) writes a valid strip and never overflows. -- PlasmaEffect (D3) on a 1D layer same contract: valid 1D strip, no overflow. -- SpiralEffect (D2) on a 3D layer: extrude copies z=0 to every z>0 (stateless D2 contract). -- FireEffect (D2, stateful — heat buffer sized to w×h) extrudes cleanly across z on a 3D layer. -- ParticlesEffect (D2, stateful — trail sized to w×h×cpl) extrudes cleanly across z on a 3D layer. - -`test/unit/light/unit_Layer_live_modifier.cpp` -*Also touches: RotateModifier, ModifierBase.* - -- With a Rotate present, the live pass rotates the gradient each frame as the angle advances — so two frames at different times differ. A static GradientEffect alone would produce identical frames, so any difference is the live remap. -- PAY-FOR-WHAT-YOU-USE: a Layer with no live modifier must NOT run the live pass — the static gradient is byte-identical across frames regardless of the clock. -- A DISABLED Rotate must not run the live pass either (the gate keys off ENABLED live modifiers). Same static gradient → identical frames. -- COALESCED REBUILD: two beat-driven modifiers (RandomMap) on one Layer both ask for a rebuild on a beat; Layer::loop() must rebuild ONCE (not re-enter onBuildState per modifier) and the Layer must stay valid — the composed mapping changes, no crash. - -`test/unit/light/unit_Layer_modifier_chain.cpp` -*Also touches: ModifierBase.* - -- Region (left half) THEN Multiply (2× mirror): the logical box folds twice. On a 16-wide axis: Region 0..50 → 8, then Multiply 2 → 4. Both modifiers apply — the second is no longer dead weight. -- Order matters: Region-then-Multiply differs from Multiply-then-Region. Region's percentage applies to whatever box it sees, so the composed logical size differs. -- A DISABLED middle modifier is skipped — the chain folds only the enabled ones. - -`test/unit/light/unit_Layer_phase_animation.cpp` -*Also touches: MetaballsEffect, SpiralEffect, LavaLampEffect, SpiralEffect.* - -- Metaballs visibly changes over 100ms even when per-tick dt is sub-millisecond (no phase-accumulator truncation). -- SpiralEffect advances at desktop speed (the spiral rotates across 100ms). -- LavaLamp animates across 100ms (blobs move). -- Spiral animates across 100ms (rotation visible). -- Replace path: swap one effect for another mid-flight (same shape as HttpServerModule::handleReplaceModule) and confirm the new effect animates. Replacing one effect with another mid-tick (HttpServerModule's swap path) leaves the new effect animating, not frozen. - -`test/unit/light/unit_Layer_sparse_mapping.cpp` - -- Dense grid: every box cell is a light, so no LUT — the identity/memcpy fast path is preserved exactly (the grid short-circuit). -- Serpentine grid: dense (every box cell is a light, so the count check alone would pick the identity fast path) but SHUFFLED (driver index i != box cell i). isNaturalOrder() measures that from the coords and routes it through the box->driver LUT instead. This is the lever for exercising the non-identity mapping path without a sparse layout or a modifier. -- Sparse sphere: a LUT is built; its destinations are driver indices in [0, lightCount), and the render buffer stays the dense bounding box. -- Sphere + Mirror: the modifier's box-coordinate destinations are translated into driver-index space; no destination escapes [0, lightCount). -- 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. -- Region carving: a RegionModifier shrinks the Layer's LOGICAL box to the region (so the effect renders only there), and the LUT maps each region cell to its box cell at the start offset — every destination in range, none outside the region. The driver buffer still holds all physical lights; cells outside the region simply get no logical source (dark). Default 0/100 = full box (the no-carve fast path) is covered by unit_RegionModifier; here we carve a quarter. - -`test/unit/light/unit_Layer_zero_grid.cpp` -*Also touches: RainbowEffect, NoiseEffect, PlasmaEffect, SpiralEffect, MetaballsEffect, RingsEffect, RipplesEffect, LavaLampEffect, FireEffect, ParticlesEffect, GameOfLifeEffect, GEQ3DEffect, PaintBrushEffect.* - -- Rainbow on 0,0,0 grid: no crash. -- Noise on 0,0,0 grid: no crash. -- Plasma on 0,0,0 grid: no crash. -- Spiral on 0,0,0 grid: no crash. -- Metaballs on 0,0,0 grid: no crash. -- Rings on 0,0,0 grid: no crash. -- Ripples on 0,0,0 grid: no crash. -- LavaLamp on 0,0,0 grid: no crash. -- Fire on 0,0,0 grid: no heat buffer allocated, no crash. -- Particles on 0,0,0 grid: no trail buffer allocated, no crash. -- GameOfLife on 0,0,0 grid: no heap alloc for 0 cells, no crash. -- GEQ3D / PaintBrush on 0,0,0 grid: audio effects, no crash with no buffer. -- _PaintBrushEffect on 0,0,0 grid_ - -## Layers - -`test/unit/light/unit_Layers_container.cpp` -*Also touches: Layer.* - -- A Layers container with one child Layer must produce the same output as that Layer used directly (no-op container). -- With two child Layers, each one's loop() runs and writes its own buffer (the container iterates all enabled children). -- Multi-layer composition: Drivers blends ≥2 enabled Layers into its own output buffer and hands THAT to drivers (not a single Layer's buffer). Bottom layer overwrites; top layer blends per its blendMode/opacity. This is the end-to-end pin for the composite loop in Drivers::loop. -- Disabling the top layer drops cleanly to the single (bottom) layer — no crash, the driver now sees the bottom layer's content. Pins the robustness path. -- Drivers' composition/output-buffer allocation contract (architecture.md § Adaptive allocation). The driver output buffer exists ONLY when the pipeline must blend into physical space; otherwise the lone layer's buffer is handed to drivers directly (zero-copy). dynamicBytes() reflects outputBuffer_.bytes(), so it's 0 ⇔ no buffer. Pins all three cases in one place: 1. one identity (no-LUT) layer → NO output buffer (zero-copy) 2. two enabled layers → output buffer (must composite) 3. one layer WITH a LUT → output buffer (must map logical→physical) -- activeLayer() returns the first enabled child, or the only child if all are disabled (so dimensions stay queryable during boot/toggle-off). -- firstEnabledLayer() is the output-selection counterpart to activeLayer(): it never falls back to a disabled layer, so it returns nullptr exactly when nothing renders. -- If the container holds only non-Layer children, activeLayer() returns nullptr (the role-guard skips, never miscasts). - -## Layouts - -`test/unit/light/unit_Layouts_container.cpp` - -- Disabled layouts contribute nothing; enabled siblings shift down to close the gap (no index holes). -- Disabling the Layouts container itself zeroes totalLightCount and yields no coordinates. - -`test/unit/light/unit_Layouts_mutation.cpp` - -- Add a single layout: the container reports its light count and iterates it. -- Add more than one layout (mixed types): counts sum, indices stitch end-to-end. -- Replace a layout with a different type at the same slot: the other layouts and their order are preserved; only the replaced slot's contribution changes. -- Remove a layout: it leaves the tree, the remaining layouts shift to close the gap, and the total drops by exactly the removed layout's light count. - -`test/unit/light/unit_Layouts_toggle_cycle.cpp` -*Also touches: Layer, Drivers.* - -- 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. -- loopbackTxPin (optional lane-0 TX override) is bound always, hidden until the test is on — same conditional-control contract as loopbackRxPin. The override's lane-0 substitution is hardware-only (lcdLanes==0 on desktop); the visibility contract is host-testable here via the shared helper (toggles loopbackTest both ways and asserts the control stays bound while flipping visibility). - -`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` - -- A fresh LUT carries no mapping (hasLUT==false, logicalCount==0); BlendMap takes the fast identity copy path. -- setIdentity(N) declares a 1:1 mapping for N lights without allocating a LUT; forEachDestination still iterates correctly. -- Each logical light can map to a different count of physical lights; forEachDestination yields every mapped index in order. -- When no single contiguous block fits (forced via the test cap) but total heap allows it, build() pages the destinations array. The mapping must read back identically to a single-alloc build — paging is an allocation detail, not a behaviour change. isPaged() confirms the fallback actually engaged. -- build() returns false on genuine exhaustion — total free heap (minus the reserve) can't hold the destinations — so the caller degrades to 1:1. Forced here via a non-zero freeHeap is desktop-only-unavailable, so this case pins the paged path's success and the boundary; the tier-3 false path is covered by the Layer sparse-mapping degrade test on real heap limits. -- free() releases memory and resets counts; build() can be called again to install a fresh mapping. - -## MetaballsEffect - -`test/unit/light/unit_MetaballsEffect.cpp` - -- One tick on a 16×16 grid leaves at least one non-zero byte in the layer buffer (proves the effect rendered). -- Pixels at opposite corners of a 32×32 grid differ in colour (the effect is not flat-filling the buffer). - -## ModuleFactory - -`test/unit/core/unit_ModuleFactory.cpp` - -- registerType(name) instantiates a probe of T to read its role(), then stores name+role+constructor for later create() calls. -- create(name) returns a heap-allocated instance whose role and typeName match what was registered. -- create() returns nullptr for an unknown name or a nullptr name (no crash on bogus input). -- typeName/typeRole with an out-of-range index returns nullptr / Generic safely (never UB). -- The factory grows its registry capacity dynamically — registering 10+ extra types past the initial size still works and every name stays discoverable. - -## MoonLive - -`test/unit/core/unit_moonlive_compiler.cpp` - -- _compileSource: fill(r,g,b) fills every light_ -- _compileSource: setRGB(index, r,g,b) writes one pixel_ -- REMARK #1: every argument is an expression — random16 in ANY slot. -- REMARK #2: a literal / random16 bound may be a uint16 (0..65535), not capped at 255. -- _compileSource: out-of-range index is bounds-rejected at runtime_ -- _compileSource rejects malformed programs with a diagnostic, never crashes_ -- _MoonLive.compile(source) on a bad script leaves the engine !ok with an error_ -- VREG REUSE: a chain of calls must fit the small device register file. Each argument temp dies once its call consumes it and is recycled, so peak register pressure stays low no matter how many calls a statement nests — setRGB with all four arguments a random16 still compiles. -- DOMAIN-NEUTRAL: the core compiler owns no function names. With an EMPTY table it knows nothing — `setRGB`/`fill`/`random16` are all "unknown function". The LED vocabulary lives only in the host's table; a different host registers different names. (Remark #3.) -- _MoonLive recompiling swaps the program live (fill <-> setRGB)_ -- STAGE 1 CONTROLS — parse layer: a `uint8_t name = def; // @control min..max` declaration surfaces a DeclaredControl, and a declared name used in a statement resolves to it. -- _compileSource: malformed control declarations fail with a diagnostic, never crash_ - -`test/unit/core/unit_moonlive_fill.cpp` - -- _MoonLive emitFill produces a non-empty routine_ -- _MoonLive emitFill rejects a too-small buffer (degrades, no overrun)_ -- _MoonLive emitFill/emitAnimatedFill reject a null output buffer (no crash)_ -- _MoonLive compiles and fills a buffer with the chosen colour_ -- _MoonLive run on zero lights writes nothing (robust to empty)_ -- The native routines write channels +0/+1/+2 per light, so a layer with fewer than 3 channels per light can't hold RGB — run() must leave it untouched, not overrun it. -- _MoonLive recompile swaps the colour; free returns to !ok_ -- _MoonLive animated fill derives colour from the per-frame t_ -- _platform allocExec returns usable executable memory, freeExec releases it_ -- _MoonLive controls: declaredControls + controlSlot seeded from the default_ -- _MoonLive controls: arena address is STABLE across a recompile and the slot value survives_ -- _MoonLive controls: free() releases the arena (no stale slot after teardown)_ - -`test/unit/core/unit_moonlive_ir.cpp` - -- _MoonLive compiled fill is BEHAVIORALLY identical to the hand-encoded emitFill (golden)_ -- _MoonLive compiled fill is robust: zero lights writes nothing_ -- _MoonLive compileSource degrades on a too-small code buffer_ -- _MoonLive compiled setRGB writes one pixel; out-of-range is bounds-rejected_ -- _MoonLive control: a declared control reads the arena live (no recompile on value change)_ -- _MoonLive control survives a host call (kArg4 live across random16)_ - -## MoonModule - -`test/unit/core/unit_MoonModule.cpp` - -- setup() and teardown() each fire exactly when called and update their respective state flags. -- name() starts empty; setName() copies the string into the internal 16-byte buffer. -- typeName (set by the factory) is independent of name; setName doesn't touch typeName so a human-renamed module still serializes under its real type. -- dirty()/markDirty()/clearDirty() round-trip cleanly (the bit FilesystemModule polls for save scheduling). -- parent() starts null; setParent() records the upstream container for tree walks. -- Adding Uint8/Bool controls stores live pointers to the module's fields, so changes propagate either direction (field ↔ control->ptr). -- controls().clear() empties the list; calling onBuildControls() again repopulates it (the standard rebuild path). -- addReadOnly binds a char buffer the UI can render; updating the buffer is visible through control.ptr. -- addSelect binds a uint8 + an options array (stored in aux) — control.max carries the option count. -- addProgress binds a uint32 plus a "total" value (in aux) — the UI renders value/total as a progress bar. -- enabled defaults to true; setEnabled flips the universal enable gate (Scheduler and parent containers respect it). -- addBool binds a bool field — toggling the field updates control.ptr's view. - -`test/unit/core/unit_MoonModule_control_change_gate.cpp` -*Also touches: GridLayout, MultiplyModifier, NoiseEffect, Drivers.* - -- Layout and Modifier modules opt in to rebuild on a control change (their controls reshape the pipeline). -- Effects and Drivers opt out — their controls are values read directly in the hot path, no rebuild needed (prevents slider stutter). - -`test/unit/core/unit_MoonModule_lifecycle.cpp` - -- A parent's default loop() fans out to every enabled child — no per-container boilerplate needed. -- Disabled children are skipped during propagation (the universal enable-gate). -- Modules that override respectsEnabled() to false (NetworkModule, SystemModule, …) tick regardless of their enable bit. -- loop20ms / loop1s use the same gate-and-propagate rule as loop(). -- A leaf module (no children) ticks safely as a no-op with no accumulated timing. -- Each child's loopTimeUs() reflects its own accumulated cost (Scheduler reads per-child timing, not the parent's sum). - -`test/unit/core/unit_MoonModule_movechild.cpp` - -- Moving a child to its current index returns false and changes nothing. -- Moving a child forward shifts intervening children leftward to close the gap. -- Moving a child backward shifts intervening children rightward to make room. -- Single-position moves work in either direction (UI's up/down arrow buttons). -- A target index beyond childCount() is refused (returns false, tree untouched). -- A module that isn't actually a child of the parent is refused. -- Middle-to-middle moves preserve the integrity of every sibling's index. - -`test/unit/core/unit_MoonModule_replacechild.cpp` - -- Replacing position 1 swaps that child while leaving siblings and child count untouched. -- The returned old child has its parent cleared; the fresh child has its parent set to the container. -- An out-of-range index returns nullptr and the tree (plus the rejected replacement's parent pointer) stays untouched. -- A nullptr replacement returns nullptr and leaves the tree intact. -- After replace, the caller follows the lifecycle order: onBuildControls → setup → onBuildState on the fresh module, then teardown on the old. - -## MultiplyModifier - -`test/unit/light/unit_MultiplyModifier.cpp` - -- _MultiplyModifier advertises D3 dimensions_ -- Defaults (multiply 2/2/1) → a 128×128 physical grid folds to a 64×64 logical box. -- _MultiplyModifier logical size on Z_ -- FAN-OUT (fold direction): with the defaults (mult 2, mirror XY), all four physical CORNERS fold onto the single logical pixel (0,0) — the inverse of the old "logical (0,0) → 4 physical corners". This is the kaleidoscope fold made concrete. -- mirrorX only: two physical columns fold to the same logical column (original + its horizontal reflection). The logical box is 64 wide. -- All multipliers 1 → identity: the box is unchanged and every coord folds to itself. -- Tiling WITHOUT mirror repeats (does not reflect): physical x=64 (tile 1) folds to logical x=0, same as physical x=0 — both tiles map identically, no reflection. -- multiplyZ on a 2D (depth-1) layout is a no-op: the effective multiplier clamps to the axis extent (1), so depth stays 1 and the layer isn't blanked. -- A multiplier larger than the axis extent clamps to the extent. -- REGRESSION (🐇): a non-divisible extent leaves a leftover edge strip that the tiles don't cover — those pixels must be DROPPED, not wrapped back into a tile (which would duplicate the edge). 5-wide, multiply 2 → tile width 2, covers pixels 0..3; pixel 4 is the leftover and has no tile. - -## NetworkModule - -`test/unit/core/unit_NetworkModule.cpp` - -- setWifiCredentials copies SSID + password into internal buffers and raises the dirty flag so the next loop1s() applies them. -- A nullptr SSID is silently ignored (no copy, no dirty flag) — guards against a bogus caller. -- A nullptr password is treated as empty (open networks), still copies SSID and marks dirty. -- An over-length SSID (100 chars) is truncated cleanly into the 33-byte buffer; ASAN catches any overflow. -- After setup(), NetworkModule exposes a `mode` read-only control whose value reflects the current state-machine state. On the desktop platform every network init stub returns false, so the cascade lands on Idle. -- 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. - -`test/unit/core/unit_NetworkModule_ethernet.cpp` - -- The enum values are a wire contract: the Select index, the ethInit() switch, and every deviceModels.json `ethType` all agree on these. Pin them so a reorder fails here. -- Desktop has no Ethernet: the default PHY type is ethNone, so a board that never pushes an eth config still reports "no Ethernet" and the cascade falls through. -- The platform seam must accept any runtime config and never bring Ethernet up on desktop — ethInit() returns false so NetworkModule cascades to WiFi/AP. Pushing a fully-populated W5500 config and an RMII config both leave ethInit() false and ethConnected() false; ethStop() is safe to call when nothing is running. - -## 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 - -`test/unit/light/unit_NoiseEffect.cpp` -*Also touches: PlasmaEffect, RainbowEffect.* - -- One tick on an 8×8 grid leaves at least one non-zero byte (noise paints somewhere). -- Opposite corners of a 16×16 grid carry different colours (noise is not flat). -- Noise and Rainbow produce visibly different frames on the same grid (sanity check that they're distinct algorithms). -- 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. - -## Palette - -`test/unit/light/unit_Palette.cpp` - -- _Palette: gradient endpoints land on the first/last stop colours_ -- _Palette: a mid-gradient sample interpolates between stops_ -- _Palette: colorFromPalette index 0 reads entry 0; brightness scales_ -- _Palette: the index wraps at 255→0 (no out-of-range read)_ -- _Palette: a degenerate (empty) gradient is all black, never out-of-bounds_ -- _Palettes::active swaps the global palette on setActive_ - -## 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. -- loopbackTxPin (optional lane-0 TX override) is bound always, hidden until the test is on — same conditional-control contract as loopbackRxPin. The override's lane-0 substitution is hardware-only (parlioLanes==0 on desktop); the visibility contract is host-testable here via the shared helper (toggles loopbackTest both ways and asserts the control stays bound while flipping visibility). - -## ParticlesEffect - -`test/unit/light/unit_ParticlesEffect.cpp` - -- The trail buffer sizes to width × height × 3 bytes (one RGB per cell, used to fade existing pixels). -- A single tick is enough to paint particles into the buffer. -- Disabling the effect releases the trail buffer (dynamicBytes returns to 0). - -## PlasmaEffect - -`test/unit/light/unit_PlasmaEffect.cpp` -*Also touches: NoiseEffect.* - -- One tick on an 8×8 grid produces at least one non-zero byte. -- Opposite corners of a 16×16 grid differ in colour (the plasma is not flat-filling). -- Plasma and Noise produce visibly different frames on the same grid (sanity check that they're distinct algorithms). - -## PreviewDriver - -`test/unit/light/unit_PreviewDriver.cpp` - -- A sphere sends its SHELL lights (210), not the dense 9x9x9 box (729). -- Per-frame 0x02 RGB count matches the coordinate-table count. -- A small grid sends every light at its grid position (stride 1, exact). -- A large layout is SPATIALLY downsampled (a regular per-axis lattice, not every-Nth-flat- index) so the payload fits the send-buffer cap without the diagonal moiré that linear stride produced on a grid whose width didn't divide the stride. The wire "stride" field carries the per-axis lattice/downscale factor (colour k still maps 1:1 to coord k). -- A SPARSE layout under the cap must NOT be downsampled for its big BOUNDING BOX alone: the lattice bound is the layout's LIGHT count, not its box cell count, so a sphere whose shell fits the cap sends every light at stride 1 (a radius-8 sphere → ~812 shell lights, well under the 4096 display cap, in a 17³≈4913-cell box). (A genuinely huge sparse layout above the cap downsamples like any other — the cap is about points streamed, not box size.) -- Default fps is the rate-limited preview stream rate. -- Regression: a coordinate table dropped under backpressure must be RETRIED, and colour frames withheld until it lands — otherwise the device sends 0x02 frames the browser skips (count mismatch) and the preview freezes for the whole session. Drives loop() (where the coord-pending logic lives) with a broadcaster that drops every 0x03, then lets it through. -- Regression: deleting the active Layer must not leave a driver holding a dangling layer_ pointer. Previously Drivers::passBufferToDrivers early-returned when the active Layer was null, leaving PreviewDriver's layer_ pointing at the freed Layer; the next onBuildState read layer_->layouts() on freed memory and crashed the device (LoadProhibited → boot loop, since the broken tree persists). Now passBufferToDrivers clears the drivers' layer_/sourceBuffer_ to null, a safe idle state. This drives the real path: Drivers bound to a Layers CONTAINER (self-healing), the Layer removed, then buildState re-resolves activeLayer()=null. -- Coordinates are sent ONLY when the geometry changes or a new client connects — never per-frame and never on a timer (a periodic full-table rebuild would starve the tick). A new client (clientGeneration bump) re-sends immediately so a page refresh shows the preview at once. Driven through loop() with a frozen clock for determinism. -- A full-res RGB frame is sent through the RESUMABLE buffered path (sendBufferedFrame), whose body is the DRIVER (consumer) buffer itself — no copy. For a dense identity grid that's the Layer's dense box buffer; for a sparse/mapped layout it's the LUT-mapped output buffer (the real lights), the same buffer the LED drivers consume — NOT the dense box. -- Sparse layout: the buffered send streams the LUT-mapped DRIVER buffer (only the real lights, in driver order), exactly like the LED drivers — NOT the dense bounding box. So coordCount == the shell count and the frame is sent whole at full res through the resumable path. -- Dense-grid CLOSED-FORM downsample, exact colour placement: a 200×1 strip pinned over a small cap strides in x only, so the kept lights are columns 0,s,2s,… The colour pass must read each from its dense buffer index (closed-form x for a 1-row grid) and pack them in the SAME order as the coord table — no forEachCoord. Painting a known colour at a kept column and finding it at the matching frame position pins the index math + the lattice order. -- ADAPTIVE FRAME RATE: while a buffered send is still draining (a slow link), loop() must NOT start a new frame — it waits for bufferedSendIdle(). So the effective rate self-limits to the link. -- USE-AFTER-FREE GUARD: a geometry rebuild (resize) frees+reallocs the producer buffer, so any in-flight buffered send (which holds a pointer into it) MUST be cancelled in onBuildState before the buffer goes away — else drainPreviewSend would read freed memory. - -## RainbowEffect - -`test/unit/light/unit_RainbowEffect.cpp` - -- A single frame on a 4×4 grid leaves the buffer non-zero (rainbow always paints somewhere). -- Pixel (0,0) carries a lit palette colour — confirms the effect writes a real RGB there. -- Distant pixels carry different hues (the rainbow gradient is spatial, not uniform). - -## RandomMapModifier - -`test/unit/light/unit_RandomMapModifier.cpp` -*Also touches: Layer.* - -- A remap leaves the logical box unchanged. -- The core property: a true bijection over [0, w*h*d) — every destination index appears exactly once (no gaps, no duplicates). -- Deterministic seed → reproducible permutation (what makes it testable). -- Reshuffling (a beat) changes the mapping, still a bijection. -- Robustness: an empty (0×0×0) box must not crash — it folds to a no-op. -- A resize (different box count) rebuilds the permutation to the new size. -- _RandomMapModifier loop() reshuffles on a beat (bpm 60 ≈ 1/s)_ -- _RandomMapModifier loop() with bpm 0 never reshuffles (frozen)_ - -## RegionModifier - -`test/unit/light/unit_RegionModifier.cpp` - -- Default region (0/100 on every axis) is the full box: identity size, no rejection. -- Half of an axis, half-open: end=50 on 128 → region width 64, not 65. -- Two abutting regions tile a 128-wide axis with no overlap and no gap. -- A physical coord inside the region folds to region-local (subtract the start pixel); a coord outside is rejected. -- Rounding rule on a small panel: start floors, end ceils to an exclusive pixel. start 33 / end 66 on a 4-wide axis → floor(1.32)=1 .. ceil(2.64)=3 → pixels 1,2. -- A region that rounds to nothing still gets a 1-pixel floor. -- OFF-SCREEN: a window slid half off the left edge keeps its FULL size (the effect renders at a fixed scale); only the visible half maps to physical lights. startX=-50 on 64 → window [−32, 32), span 64. Physical x 0..31 land at window-local 32..63 (the right half of the window — the visible part); the left half of the window (0..31) has no physical light, so it's dark. The effect isn't rescaled. -- A window entirely off the box maps NO lights — the layer goes dark on that axis, which is how an effect is moved completely out of view. The box still has a valid size (the effect renders), nothing just reaches the screen. -- A window stretched WIDER than the box (start<0 and end>100) renders the full span; the box shows the middle slice. startX=-50,endX=150 on 64 → window [−32, 96), span 128. -- Degenerate axes don't crash: a 1-wide axis stays 1, a 0-extent axis yields 0. - -## 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. -- loopbackTxPin is the optional TX override (transmit on it instead of pins[0] during the self-test). Like loopbackRxPin it's a conditional control: always bound (so a saved override loads), shown only while loopbackTest is on. The override's effect on the transmitted pin is hardware-only (rmtTxChannels==0 on desktop), but the conditional-visibility contract is host-testable 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_ -- _RmtLedDriver window: ledsPerPin distributes over the window, not the whole buffer_ -- _RmtLedDriver window: count 0 means the rest of the buffer from start_ -- _RmtLedDriver window: a size-1 window at 0 is the onboard-LED case_ -- _RmtLedDriver window: a start past the buffer end yields an empty slice_ -- 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_ - -## RotateModifier - -`test/unit/light/unit_RotateModifier.cpp` - -- _RotateModifier advertises a live (per-frame) modifier_ -- At the initial angle (0) the rotation matrix is the identity — every cell samples itself. -- z passes through (2D rotation) — a 3D coord's z is untouched. -- An empty box doesn't divide-by-zero or wrap: the remap is a no-op-ish transform that the Layer's live pass then treats as out-of-box (dark), never a crash. - -## Scheduler - -`test/unit/core/unit_Scheduler_unique_names.cpp` - -- A name with no collision is returned unchanged. -- The second module with a duplicate name gets " 2" suffixed; the first keeps its original name. -- Suffix counting increments past existing "-2" / "-3" suffixes ("Layer", "Layer-2", "Layer" → "Layer-3"). -- deduplicateNamesInTree() walks the entire module tree in one pass and disambiguates every duplicate (used after persistence load). -- firstByName(name) returns the first match in DFS order, or nullptr if no module carries that name. -- If the disambiguating suffix would overflow the 16-byte name buffer, ensureUniqueName refuses to truncate and keeps the colliding name (sharp edge, documented). - -## SineEffect - -`test/unit/light/unit_SineEffect.cpp` - -- _SineEffect writes non-zero RGB data_ -- _SineEffect amplitude 0 yields a black buffer_ -- _SineEffect varies across the x axis (R channel follows x)_ -- _SineEffect survives a 0x0x0 grid_ - -## Sort - -`test/unit/core/unit_Sort.cpp` - -- _insertionSort orders ints ascending_ -- _insertionSort with a custom (descending) comparator_ -- _insertionSort orders C-strings (the device-name use case)_ -- _insertionSort is stable — equal keys keep input order_ -- _insertionSort handles empty and single-element arrays_ - -## SphereLayout - -`test/unit/light/unit_SphereLayout.cpp` - -- lightCount() must equal the number of points forEachCoord emits — they share one shell predicate, so allocation and fill can never disagree. -- The sphere is HOLLOW: the centre lattice point (r,r,r) is never emitted, and neither is any interior point (distance < radius-0.5 from centre). -- radius = 1 is the smallest hollow sphere: the 6 axis neighbours (d^2=1) plus the 12 edge points (d^2=2) of the centre — 18 lights, no centre. -- The shell is symmetric about the centre: for every emitted point its mirror through the centre is also emitted (a sphere has no preferred direction). -- Physical indices are sequential 0..N-1 over the emitted shell points (no gaps from the unindexed lattice voids), so the buffer maps 1:1 to emitted lights. -- Default radius is a sensible small sphere (not 0, not huge). - -## SpiralEffect - -`test/unit/light/unit_effects_render.cpp` -*Also touches: RingsEffect, RipplesEffect, LavaLampEffect.* - -- 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). -- 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. - -## SystemModule - -`test/unit/core/unit_SystemModule.cpp` - -- On the desktop platform (MAC DE:AD:BE:EF:CA:FE), the auto-generated device name is "MM-CAFE" (last two MAC bytes). -- deviceName is bound as a Text control to the MAC-derived default ("MM-CAFE" on the desktop platform). -- deviceName is the single network identity, so SystemModule keeps it a valid hostname. A live edit to an invalid value ("My Room!") is coerced on the next loop1s tick (mm::sanitizeHostname), the same path mDNS/AP/DHCP read — so they never see spaces. -- An all-invalid name collapses to empty after sanitising; the MAC fallback then fills it, so deviceName is never empty (mDNS/AP/DHCP always have a name to register). -- An already-valid name is left untouched (idempotent) — a normal user name survives. -- The `bootReason` control is populated from platform::resetReason; on desktop it reports "OK". -- SystemModule accepts user-added Peripheral children (sensors/actuators the user solders on); the role string drives the type-picker filter + add policy. -- Regression: SystemModule overrides setup() and loop1s(); both must chain to MoonModule's base so a Peripheral child's setup()/loop1s() actually fire. Without the chain a sensor would never init or poll (the "children miss callbacks" trap from history/decisions.md). loop20ms() isn't overridden, so the base default already propagates it. -- roleName maps the new Peripheral enum to its lowercase API string. - -`test/unit/core/unit_sanitizeHostname.cpp` -*Also touches: NetworkModule.* - -- _sanitizeHostname leaves a valid hostname unchanged (idempotent)_ -- _sanitizeHostname replaces spaces with a single dash_ -- _sanitizeHostname strips punctuation and other invalid chars_ -- _sanitizeHostname trims leading and trailing dashes / invalid runs_ -- _sanitizeHostname yields empty for all-invalid input (caller falls back)_ - -## TextEffect - -`test/unit/light/unit_TextEffect.cpp` - -- Static text renders glyph pixels top-left. On a grid tall/wide enough for one line of the 6x8 font, a non-empty string lights some pixels; an empty string lights none. -- A multi-line string wraps: the second line renders on a lower row (font-height down), so a two-line string lights pixels below the first font's height. Uses the 4x6 font (height 6). -- Scroll mode advances the text over time and never crashes; on a degenerate grid it's a safe no-op. - -## Uncategorized - -`test/unit/light/unit_Layer_persistence.cpp` - -- _Layer: buffer persists across frames (no per-frame clear)_ -- _Layer: fadeToBlackBy decays the persisted buffer once per frame_ -- _Layer: multiple fade requests combine with MIN (gentlest wins, longest trail)_ -- _Layer: collected fade resets after it is consumed_ -- _Layer: onBuildState clears the buffer (a rebuild wipes stale pixels)_ - -## WaveEffect - -`test/unit/light/unit_WaveEffect.cpp` - -- _WaveEffect: sawtooth ramps 0→top across the phase_ -- _WaveEffect: triangle peaks in the middle and returns_ -- _WaveEffect: sine sits mid at the zero crossings_ -- _WaveEffect: square is low then high_ -- _WaveEffect: every type stays within the grid bounds_ -- _WaveEffect: a zero-height grid never reads out of bounds_ - -## WheelLayout - -`test/unit/light/unit_WheelLayout.cpp` - -- _WheelLayout lightCount = spokes * ledsPerSpoke and matches the iterator_ -- _WheelLayout indices are dense [0, count)_ -- _WheelLayout coordinates are non-negative (centre-shifted into address space)_ -- _WheelLayout different spoke counts give different layouts_ - -## WledPacket - -`test/unit/core/unit_WledPacket.cpp` - -- _WledPacket::build produces a valid WLED header (token/id/size)_ -- _WledPacket::readName round-trips the device name_ -- _WledPacket marker is set only when stamped, and stays WLED-valid_ -- _WledPacket::isValid rejects short / wrong-magic / null input_ -- _WledPacket::readName truncates a long name to the buffer, never overruns_ - -## crc - -`test/unit/core/unit_crc.cpp` - -- CRC-16/CCITT-FALSE has a well-known check value: "123456789" → 0x29B1. Pinning it proves the polynomial/init/reflection match the standard variant (so a fingerprint computed here matches any other CCITT-FALSE implementation). -- A change-detector: different content → (almost always) different CRC; identical content → same. -- Empty span returns the init value (no bytes processed). - -## draw - -`test/unit/light/unit_draw.cpp` - -- mm::draw::pixel() writes inside the grid and silently clips outside it (no out-of-bounds write). -- A 1D line (a row): every pixel from a.x to b.x inclusive is lit. -- A 2D diagonal: endpoints are lit and the line is contiguous (one pixel per step on the main diagonal of a square). -- A 3D line: drives all three axes, endpoints lit, no out-of-bounds on a small cube. -- A line running off the grid clips: it draws the on-grid part and stops, no crash. -- The `shorten` parameter pulls the far endpoint back toward `a` by shorten/255 (with WLEDMM *2 rounding), so an effect can sweep a partial segment. For a→b = (0,0)→(8,0): shorten 255 draws the whole line (tip at 8), 128 ≈ half (tip at (16*128/255+1)/2 = 4), 1 = just the start pixel (tip 0), 0 = nothing. This pins the rounding of the shorten branch. -- draw::blur on a 1D row matches the canonical carryover-seep reference byte-for-byte (same behaviour as FastLED blur1d / MoonLight blurRows), and is symmetric around a centred bright pixel. -- blur runs separably on every axis with extent>1: a 2D blur spreads a centre pixel to all four orthogonal neighbours; a 3D blur reaches the z neighbours too. And it never writes out of bounds. -- A glyph blits in the correct orientation — neither X-mirrored (a 'b' as a 'd') nor Y-flipped. 'L' is the ideal probe: its vertical bar must be on the LEFT and its foot on the BOTTOM row. This guards the column-bit and row-direction reads, so the DemoReel name overlay renders each letter upright and un-mirrored. - -## light_types - -`test/unit/light/unit_Coord3D.cpp` - -- _Coord3D arithmetic is per-axis_ -- _Coord3D modulo and divide fold per axis_ -- _Coord3D % and / guard a zero or degenerate axis_ -- _Coord3D equality_ - -## math8 - -`test/unit/core/unit_math8.cpp` - -- sin8: a 256-entry sine LUT centred on 128, peaking near 255 and 0 a quarter and three-quarters of the way round. cos8 is sin8 shifted a quarter turn. -- triwave8: linear up 0→255 then down 255→0, peaking at the midpoint. -- qadd8/qsub8 clamp at the 0..255 ends instead of wrapping. -- nscale8 is the recognisable spelling of scale8 (n/256 channel scale), so nscale8(x,255)==x. -- beat8: a sawtooth completing `bpm` cycles per minute. At t=0 it's 0; halfway through a beat ~128. -- beatsin8: a sine oscillating in [low,high] at bpm. Stays in range across the cycle and actually moves (not stuck at one value). -- Random8: a seeded PRNG — same seed gives the same sequence (determinism), and below(n) stays under n. Two different seeds diverge. -- atan2_8 / dist8: the geometry helpers moved here from color.h still behave. -- map8 rescales 0..255 onto [lo,hi] inclusively — the top of the input must REACH hi (FastLED's map8 == map(in,0,255,lo,hi)). Regression: an earlier scale8-based form left hi unreachable, so a one-step span (a bar height of 1) collapsed to 0 — the bug GEQ3D's height mapping hit. - -## noise - -`test/unit/core/unit_noise.cpp` - -- Determinism: the same coordinate always gives the same value (a pure function of position), so a field is reproducible frame to frame and across the 1D/2D/3D entry points at z/y = 0. -- Smoothness: neighbouring positions WITHIN a cell (sub-256 steps) differ only a little — that's what makes it value noise rather than a raw hash (which would jump randomly every step). -- Range: output is a full byte; over a swept field it uses a wide span (not stuck near one value). - -## platform - -`test/unit/core/unit_platform_clock.cpp` - -- setTestNowMs freezes platform::millis() to the given value; passing 0 restores the real clock so subsequent test cases see fresh time. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..8842ca20 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,151 @@ +# Docs site config — Material for MkDocs. Phase 0 of the docs overhaul +# (docs/backlog/docs-system-overhaul.md): render the EXISTING docs as a +# navigable, searchable site at the Pages root, with zero content changes. +# The `nav:` below imposes a top-down end-user → developer order over files +# that already exist; nothing here moves or rewrites a doc. +# +# Built in CI (release.yml deploy-pages job) into pages/ root; the web +# installer keeps its /install/ path untouched. Build locally with +# uv run scripts/docs/build_docs.py --strict +site_name: projectMM +site_description: High-performance LED & DMX lighting control for ESP32 and beyond. +# Canonical deployed URL: the custom domain, with the /projectMM/ repo subpath +# kept (GitHub Pages serves the project site under that path even on the custom +# domain — every existing link uses moonmodules.org/projectMM/…, e.g. the +# installer at /projectMM/install/). Drives the sitemap, canonical tags, +# and the base for absolute URLs. +site_url: https://moonmodules.org/projectMM/ +repo_url: https://github.com/MoonModules/projectMM +repo_name: MoonModules/projectMM +edit_uri: edit/main/docs/ + +# Flat output (foo.md → foo.html), NOT MkDocs' default directory URLs +# (foo.md → foo/index.html). The docs' relative links (image ``, source +# links) were hand-authored with `../` counts that match the FLAT layout — the +# same counts GitHub uses when viewing the raw .md. Directory URLs add one path +# level and break every relative asset link by one hop (a `../../../assets/…` +# preview resolves to `/moonmodules/assets/…` and 404s). Keeping flat URLs makes +# the same links resolve in BOTH the rendered site and GitHub's raw view — one +# link that works everywhere, no per-file rewrite. +use_directory_urls: false + +theme: + name: material + # The MoonModules moon-man — browser-tab favicon + the header logo (same PNG + # the web installer uses, copied into docs/assets/). Paths are relative to docs/. + favicon: assets/favicon.png + logo: assets/favicon.png + palette: + # Dark theme matching the retired landing page's accent (#a78bfa). + scheme: slate + primary: deep purple + accent: deep purple + features: + - navigation.instant # SPA-style nav, no full reload + - navigation.tracking # URL reflects the active anchor + - navigation.top # back-to-top button + - navigation.sections # top-level nav groups render as sections + - search.suggest + - search.highlight + - content.code.copy # copy button on code blocks (matters once snippets land) + +markdown_extensions: + - admonition + - attr_list + - md_in_html + - toc: + permalink: true + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.superfences + - pymdownx.inlinehilite + # Renders Material icon shortcodes (`:material-flash:` → the ⚡ glyph) used on + # the landing page's buttons and card grid. Without this they show as literal + # text. The emoji_index/generator pair is Material's documented icon setup. + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + # Snippets is the Phase-3 de-duplication mechanism: `--8<--` pulls real + # source lines into a doc so a fact lives once (in the .h) and renders in + # the .md. Enabled now (harmless with no snippets yet) so later phases need + # no config change. base_path lets a snippet reference src/ from the repo root. + - pymdownx.snippets: + base_path: [!relative $config_dir] + check_paths: true + +plugins: + - search + +# Column widths + preview-image sizing for the catalog tables (see mkdocs_hooks.py). +extra_css: + - assets/extra.css + +# Build-time hooks (scripts/docs/mkdocs_hooks.py): generate the test-inventory pages +# from the test files (never committed → can't drift), render each catalog page's +# prose ### blocks as a 4-column table, and rewrite out-of-docs source links to +# GitHub URLs. See the module docstring. +hooks: + - scripts/docs/mkdocs_hooks.py + +# history/ and backlog/ are internal (agent-facing, transient) — kept OFF the top +# nav (they're not in the `nav:` tree) but still BUILT into the site, so the many +# doc-to-doc links into them (a spec citing a design study, a decision record) +# resolve as normal relative links instead of 404ing. Reachable by following a +# link, not by browsing the menu. They're already public on GitHub. One build +# mechanism, no exclusion special-case, no dangling internal links. +# (The web installer is a separate top-level app outside docs_dir — MkDocs never +# sees it, so it needs no exclusion.) + +# Link validation: warn (don't fail) on cross-doc anchors. The existing docs +# carry a backlog of links to renamed/removed section anchors — surfacing them +# is useful, but fixing ~400 of them is its own cleanup, not this additive +# site-standup. Phase 0 fails the build only on missing *files* / bad nav, not +# stale anchors. (A later phase can flip anchors to `error` once swept.) +validation: + nav: + omitted_files: ignore # history/backlog intentionally absent from nav + links: + not_found: warn + anchors: warn + absolute_links: ignore + +# Top-down navigation over the existing files. Two audiences, in order: +# a user path first (what it is → install → use → effects), then the developer +# reference (architecture → modules → source-level). history/ and backlog/ are +# internal and deliberately absent from the published site. +nav: + - Home: index.md + - Getting started: + - Install & first light: gettingstarted.md + # The web installer is a separate app deployed at /install/ (staged + # verbatim by the release workflow, not built by MkDocs). Link out to it. + - Web installer: /projectMM/install/ + - Effects & building shows: + - Effects: moonmodules/light/effects/effects.md + - Layouts: moonmodules/light/layouts/layouts.md + - Modifiers: moonmodules/light/modifiers/modifiers.md + - Drivers: moonmodules/light/drivers/drivers.md + - Live scripting: moonmodules/light/moonlive/MoonLiveEffect.md + - Supporting: moonmodules/light/supporting/supporting.md + - Core: + - UI: moonmodules/core/ui/ui.md + - Supporting: moonmodules/core/supporting/supporting.md + - Tests (what we verify): + - Unit tests: tests/unit-tests.md + - Scenario tests: tests/scenario-tests.md + - Developer reference: + - Architecture: architecture.md + - Coding standards: coding-standards.md + - Building: building.md + - Testing strategy: testing.md + - Performance: performance.md + - Hardware reference: + - ESP32-S31 coreboard: reference/esp32-s31-coreboard.md + # The per-module summary pages above (Effects & building shows › Supporting, + # Core › UI/Supporting) are the docs-v2 surface: each 4-column table row links + # to the module's generated technical page (moonmodules/{core,light}/moxygen/ + # .md, from each `.h`'s /// comments) and, temporarily during the + # migration, to the original hand-written page. Those per-module pages + # (moonmodules/core/*.md, moonmodules/light/*.md, .../drivers/*.md) still BUILD + # and are reachable by link, but are OFF the menu — they collapse away entirely + # at the Stage-5 switchover once every module's content is absorbed into its /// . diff --git a/scripts/MoonDeck.md b/scripts/MoonDeck.md index 96e62fe8..fe908366 100644 --- a/scripts/MoonDeck.md +++ b/scripts/MoonDeck.md @@ -15,7 +15,7 @@ Below: the UI behaviours common to every card, described once, then one section - **Tab persistence** — selected tab survives page refresh. - **Process detection** — on page load, checks if projectMM or idf.py is already running and shows Stop button. - **Network bar** (top of the sidebar): switch between known networks. Each network holds its own device list, last-used serial port, and WiFi credentials (consumed by Improv). On startup, MoonDeck auto-selects the network whose subnet matches the host's current LAN — moving the laptop between networks usually requires no clicks. Manual override (the dropdown) pins the selection until the pinned network's subnet stops matching the host. Add / Rename buttons next to the dropdown manage the catalog. State persisted in `scripts/moondeck.json` under `networks` + `active_network`. -- **Board picker** on each device row: dropdown of physical boards from [docs/install/deviceModels.json](../docs/install/deviceModels.json) — the same catalog the web installer uses. When the device's firmware uniquely identifies one board (e.g. `esp32-eth` → Olimex Gateway), MoonDeck auto-deduces and mirrors the value to the device's `deviceModel` control on [SystemModule](../docs/moonmodules/core/SystemModule.md) via `POST /api/control` on next discover. For firmwares with no unique board (`esp32` runs on multiple), the user picks; MoonDeck pushes that value too. A device-reported board not in the catalog still shows up as ` (unknown)` so the value survives. MoonDeck's picker is a **text dropdown for an already-running device** — distinct from the web installer's flash-time *picture* board picker; both read the same catalog, but MoonDeck doesn't need the per-board `image`/`url` fields (those are installer-picker UX). Selecting a board pushes its full catalog config — each entry is a list of `{type, id, parent_id?, controls?}` module units (the [nested catalog schema](../docs/install/README.md), add-then-configure), so MoonDeck adds the board's modules (`POST /api/modules`) then sets their controls (`POST /api/control`); see `_push_board_to_device` in [moondeck.py](moondeck.py). +- **Board picker** on each device row: dropdown of physical boards from [web-installer/deviceModels.json](../web-installer/deviceModels.json) — the same catalog the web installer uses. When the device's firmware uniquely identifies one board (e.g. `esp32-eth` → Olimex Gateway), MoonDeck auto-deduces and mirrors the value to the device's `deviceModel` control on [SystemModule](../docs/moonmodules/core/SystemModule.md) via `POST /api/control` on next discover. For firmwares with no unique board (`esp32` runs on multiple), the user picks; MoonDeck pushes that value too. A device-reported board not in the catalog still shows up as ` (unknown)` so the value survives. MoonDeck's picker is a **text dropdown for an already-running device** — distinct from the web installer's flash-time *picture* board picker; both read the same catalog, but MoonDeck doesn't need the per-board `image`/`url` fields (those are installer-picker UX). Selecting a board pushes its full catalog config — each entry is a list of `{type, id, parent_id?, controls?}` module units (the [nested catalog schema](../web-installer/README.md), add-then-configure), so MoonDeck adds the board's modules (`POST /api/modules`) then sets their controls (`POST /api/control`); see `_push_board_to_device` in [moondeck.py](moondeck.py). - **Pin profiles** on each device row (Save / Apply): a profile captures the device's current pin/peripheral config — the driver, board, network and audio modules and their control values, read from `GET /api/state` (effects/layouts and the always-on Preview are excluded; a profile is the *physical wiring*, not animation state). **Save** (`POST /api/save-profile`) stores it as a named entry under that device in `moondeck.json`; **Apply** (`POST /api/apply-profile`) re-pushes it through the same add-then-configure fan-out the board picker uses (`_apply_modules_to_device`, shared with `_push_board_to_device`). The captured unit shape is identical to a `deviceModels.json` entry's `modules`, so save→apply round-trips. Use it to restore GPIOs after a reflash wipes config, or to clone a working setup onto a second identical rig. ## PC Tab @@ -61,16 +61,16 @@ While the app is running, MoonDeck shows the button as **Stop** (a 5-second poll ![Installer2](../docs/assets/ui/installer2.png) ![Installer3](../docs/assets/ui/installer3.png) -Locally preview the web installer page at without tagging a release. Stages `docs/install/index.html` + `src/ui/install-picker.js` into `build/install-preview/` and serves them via Python's `http.server` on port 8000. +Locally preview the web installer page at without tagging a release. Stages `web-installer/index.html` + `src/ui/install-picker.js` into `build/install-preview/` and serves them via Python's `http.server` on port 8421. ```bash uv run scripts/run/preview_installer.py -# open http://localhost:8000/ in Chrome / Edge / Opera +# open http://localhost:8421/ in Chrome / Edge / Opera ``` Long-running — MoonDeck shows **Stop** while the server is up. Two modes, picked automatically: -- **Render-only.** When no `build/esp32-*/projectMM.bin` is present, the picker populates against the real GitHub Releases API and dropdowns work, but clicking **Install** fails because the local server has no `releases/` tree. Useful for iterating on HTML / CSS / JS without burning a build. Equivalent to "Recipe A" in [docs/install/README.md](../docs/install/README.md). +- **Render-only.** When no `build/esp32-*/projectMM.bin` is present, the picker populates against the real GitHub Releases API and dropdowns work, but clicking **Install** fails because the local server has no `releases/` tree. Useful for iterating on HTML / CSS / JS without burning a build. Equivalent to "Recipe A" in [web-installer/README.md](../web-installer/README.md). - **Flash-ready.** When at least one ESP32 build exists, the script additionally stages every `build/esp32-*/projectMM.bin` it finds into `releases/local-dev/` and generates matching Pages-relative manifests via the same `generate_manifest.py` the release workflow uses. The picker shows `local-dev` as the newest tag; clicking **Install** flashes a USB-connected ESP32 and hands off to the repository's custom orchestrator UI (Improv-Serial provisioning + SET_DEVICE_MODEL + control fan-out, all in `install-orchestrator.js` — not ESP Web Tools). End-to-end, same code paths as the public installer. This is the developer's test ground for the install flow before deploying to GitHub Pages: Web Serial works on `http://localhost` without the secure-origin requirement that gates the public site. Add `?nocache=1` to the URL to bypass the picker's 5-minute sessionStorage cache while editing. @@ -135,7 +135,7 @@ Connects to a running projectMM server, builds a minimal pipeline scaffold (Layo - `.gif` — 3-second preview animation for effects and modifiers (requires `--gif`) - `ui_overview.png` — full-page screenshot of the projectMM UI - `moondeck_pc.png`, `moondeck_esp32.png`, `moondeck_live.png` — MoonDeck tab screenshots (requires MoonDeck running on port 8420) -- `installer.png` — web installer preview (requires `preview_installer` running on port 8000) +- `installer.png` — web installer preview (requires `preview_installer` running on port 8421) Without `--force`, existing screenshots are skipped — only missing files are captured. Run with `--force` to re-capture everything (e.g. after a UI change). @@ -158,6 +158,18 @@ Also inserts MoonDeck tab screenshots and the installer screenshot into `scripts Reports unreferenced screenshots — any PNG or GIF in `docs/assets/` not mentioned anywhere in `docs/` or `scripts/`. +### build_docs + +**Preview Docs Site** — serve the documentation site (Material for MkDocs) from the `docs/` tree with live-reload, so you can view and iterate on it. Long-running: MoonDeck shows **Stop** while the server is up (like Installer Preview); a stray `mkdocs serve` is killed before a new one starts. The button passes `--serve`. + +```bash +uv run scripts/docs/build_docs.py --serve # what the button runs → http://localhost:8422/projectMM/ (auto-reload) +uv run scripts/docs/build_docs.py # one-shot build to site/ (CI parity; no server) +uv run scripts/docs/build_docs.py --strict # promote every warning to an error (local anchor audit) +``` + +The preview binds **:8422** — the [Installer Preview](#preview_installer) owns :8421 and MoonDeck :8420, so all three servers run at once. Config is `mkdocs.yml`; deps (`mkdocs-material`) are declared inline in the script, so `uv run` provisions them on first use. The two test-inventory pages and each effect/modifier's inline test list are generated from the test files at build time (`scripts/docs/mkdocs_hooks.py`), so they're never committed and can't drift; `history/` and `backlog/` are built but kept off the nav. Warnings for links to repo files outside `docs/` (rewritten to GitHub URLs) and pre-existing stale anchors are expected — the build still succeeds. + ## Live Tab @@ -415,7 +427,7 @@ Exit codes: `0` = all checks passed, `1` = device-side failure (probe or provisi - [src/core/ImprovFrame.h](../src/core/ImprovFrame.h) — the on-device parser - [src/platform/esp32/platform_esp32_improv.cpp](../src/platform/esp32/platform_esp32_improv.cpp) — the UART listener task -- [docs/install/index.html](../docs/install/index.html) — the web installer page +- [web-installer/index.html](../web-installer/index.html) — the web installer page - [src/ui/install-picker.js](../src/ui/install-picker.js) — the picker driving the install flow - [scripts/build/improv_*.py](build/) — the host-side framing helpers diff --git a/scripts/build/build_esp32.py b/scripts/build/build_esp32.py index 5f9b09da..ae1e215d 100644 --- a/scripts/build/build_esp32.py +++ b/scripts/build/build_esp32.py @@ -64,7 +64,7 @@ # `ships`: True for variants the release matrix builds + publishes. A variant can # exist here (buildable from the CLI) yet be held out of CI with ships=False. # This dict is the SINGLE source of truth — generate_firmwares.py projects it to -# docs/install/firmwares.json, which the CI matrix, the ESP Web Tools manifest +# web-installer/firmwares.json, which the CI matrix, the ESP Web Tools manifest # loops, and MoonDeck all read (check_firmwares.py guards the projection). FIRMWARES: dict[str, dict] = { # Default classic ESP32: WiFi AND Ethernet in one binary. The RMII Ethernet diff --git a/scripts/build/generate_firmwares.py b/scripts/build/generate_firmwares.py index d07e0908..a09cb4cd 100644 --- a/scripts/build/generate_firmwares.py +++ b/scripts/build/generate_firmwares.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Generate docs/install/firmwares.json from the FIRMWARES dict. +"""Generate web-installer/firmwares.json from the FIRMWARES dict. The firmware-variant list was hand-copied across the CI matrix, the two ESP Web Tools manifest loops, MoonDeck, and the docs — six copies that drifted (MoonDeck's @@ -17,7 +17,7 @@ physical hardware). See docs/architecture.md § Firmware vs board. Inputs: - --out — firmwares.json destination (docs/install/firmwares.json). + --out — firmwares.json destination (web-installer/firmwares.json). """ import argparse diff --git a/scripts/build/improv_provision.py b/scripts/build/improv_provision.py index 63092dcd..2b816689 100644 --- a/scripts/build/improv_provision.py +++ b/scripts/build/improv_provision.py @@ -220,7 +220,7 @@ def main() -> int: 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/deviceModels.json (e.g. " + help="Board name from web-installer/deviceModels.json (e.g. " "'ESP32-S3 N16R8 Dev'). Resolves the board's TX-power cap " "(controls.Network.txPowerSetting) automatically and " "pushes the board name via SET_DEVICE_MODEL after " @@ -230,7 +230,7 @@ def main() -> int: 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 (weak-powered boards → 8, see " - "docs/install/deviceModels.json) — without it the very first " + "web-installer/deviceModels.json) — without it the very first " "association fails and the cap can never arrive over HTTP.") args = ap.parse_args() @@ -279,7 +279,7 @@ def main() -> int: # same file the web installer and MoonDeck read. import json from pathlib import Path - boards_file = Path(__file__).resolve().parents[2] / "docs" / "install" / "deviceModels.json" + boards_file = Path(__file__).resolve().parents[2] / "web-installer" / "deviceModels.json" try: catalog = json.loads(boards_file.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: diff --git a/scripts/build/improv_smoke_test.py b/scripts/build/improv_smoke_test.py index 4b69ec4b..a552190a 100755 --- a/scripts/build/improv_smoke_test.py +++ b/scripts/build/improv_smoke_test.py @@ -28,7 +28,7 @@ Recommended developer test before any commit touching: - src/core/ImprovFrame.h - src/platform/esp32/platform_esp32_improv.cpp - - docs/install/index.html + - web-installer/index.html - src/ui/install-picker.js - scripts/build/improv_*.py diff --git a/scripts/check/check_devices.py b/scripts/check/check_devices.py index 8b40501a..5961ce0f 100644 --- a/scripts/check/check_devices.py +++ b/scripts/check/check_devices.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate the installer board catalog (docs/install/deviceModels.json). +"""Validate the installer board catalog (web-installer/deviceModels.json). The catalog is hand-maintained data consumed identically by three clients (the web installer, the device UI's ?deviceModel= inject, and MoonDeck), so a typo drifts @@ -27,7 +27,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent.parent -CATALOG = ROOT / "docs" / "install" / "deviceModels.json" +CATALOG = ROOT / "web-installer" / "deviceModels.json" MAIN_CPP = ROOT / "src" / "main.cpp" DOCS = ROOT / "docs" diff --git a/scripts/check/check_firmwares.py b/scripts/check/check_firmwares.py index 48b48add..d64b3670 100644 --- a/scripts/check/check_firmwares.py +++ b/scripts/check/check_firmwares.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Check that docs/install/firmwares.json is in sync with the FIRMWARES dict. +"""Check that web-installer/firmwares.json is in sync with the FIRMWARES dict. firmwares.json is a generated projection of build_esp32.py's FIRMWARES (the single source of truth), read by the CI release matrix, the ESP Web Tools manifest loops, @@ -15,7 +15,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent.parent -COMMITTED = ROOT / "docs" / "install" / "firmwares.json" +COMMITTED = ROOT / "web-installer" / "firmwares.json" # Reuse the generator's projection so the checker and generator can't disagree. sys.path.insert(0, str(ROOT / "scripts" / "build")) @@ -36,8 +36,8 @@ def main(): # newline) can't cause spurious drift; only the data matters. if actual != expected: print(f"Firmware check: {n} variants, DRIFT") - print(" docs/install/firmwares.json is stale — regenerate with:") - print(" uv run scripts/build/generate_firmwares.py --out docs/install/firmwares.json") + print(" web-installer/firmwares.json is stale — regenerate with:") + print(" uv run scripts/build/generate_firmwares.py --out web-installer/firmwares.json") sys.exit(1) print(f"Firmware check: {n} variants, 0 issue(s)") diff --git a/scripts/check/check_specs.py b/scripts/check/check_specs.py index 1e98077a..708c6fc5 100644 --- a/scripts/check/check_specs.py +++ b/scripts/check/check_specs.py @@ -13,12 +13,36 @@ SRC = ROOT / "src" SPECS = ROOT / "docs" / "moonmodules" +# The module stems that get a source-generated technical page (every `.h` under +# src/{core,light} — gen_api discovers them). A summary/overview may link its +# `moxygen/.md` in place of a `## Source` section; this set validates that link +# points at a real generated page. Import by path so this check needs no PYTHONPATH tweak. +sys.path.insert(0, str(ROOT / "scripts" / "docs")) +from gen_api import _discover_headers # noqa: E402 +_API_STEMS = {Path(h).stem for h in _discover_headers()} + # Map source directories to spec directories SOURCE_DIRS = { "core": "core", "light": "light", } + +# Subdirs under docs/moonmodules/ that are NOT hand-written active specs: +# moxygen/ — generated technical pages (no `## Source`; the page IS the source view) +# archive/ — the old per-module pages, kept for migration cross-check until Stage 5 +# deletes them; transitional, not maintained (their relative links are +# stale after the archive move — no reason to fix links on doomed files). +_NON_SPEC_DIRS = {"moxygen", "archive"} + + +def spec_md_files(): + """Every hand-written, still-active spec `.md` under docs/moonmodules/, excluding + the generated and archived subtrees (see _NON_SPEC_DIRS).""" + return (md for md in SPECS.rglob("*.md") + if _NON_SPEC_DIRS.isdisjoint(md.parts)) + + def find_moonmodules(): """Find all .h files that define MoonModule subclasses.""" modules = [] @@ -87,7 +111,7 @@ def find_spec(module_path): break # consolidated page missing — fall through to the per-module stem search # Per-module page: match by filename stem anywhere under docs/moonmodules/. - for md in SPECS.rglob("*.md"): + for md in spec_md_files(): if md.stem == name: return md return None @@ -104,24 +128,177 @@ def check_spec_freshness(source_path, spec_path): if ctrl not in spec: issues.append(f"control '{ctrl}' not in spec") + # On a consolidated catalog page (effects/modifiers/layouts/drivers) the spec + # documents MANY modules; scope the drift checks to THIS module's own block so a + # control name shared across modules (fps, fadeRate, speed…) doesn't cross-match + # another module's prose. The block is the one whose `source [.h]` link + # points at this .h. On a per-module page the block is the whole file. + block = _module_block(source_path, spec) + issues += _check_range_drift(source, block) + issues += _check_author_url_drift(source, block) return issues + +def _module_block(source_path, spec): + """The slice of `spec` documenting the module in `source_path`. On a consolidated + page, the `### ` block whose `source [.h]` link matches; else the whole spec.""" + stem = source_path.name # e.g. "BlurzEffect.h" + base = source_path.stem # e.g. "BlurzEffect" + lines = spec.splitlines() + # Find the line tying this module to its block. Two link shapes across catalog + # pages: a direct `source [.h]` (effects/modifiers/layouts) or a detail-page + # reference `[.md]` / `alt=" controls"` (drivers link to detail pages). + def _match(ln): + return (f"[{stem}]" in ln or f"[{base}.md]" in ln + or f'alt="{base} ' in ln or f'alt="A {base} ' in ln) + link_i = next((i for i, ln in enumerate(lines) if _match(ln)), None) + if link_i is None: + return spec # per-module page (or no matching link) — whole file + # Block start: the nearest `### ` heading at or above the link. + start = next((i for i in range(link_i, -1, -1) if lines[i].startswith("### ")), 0) + # Block end: the next `### ` or `## ` heading after the link. + end = next((i for i in range(link_i + 1, len(lines)) + if lines[i].startswith(("### ", "## "))), len(lines)) + return "\n".join(lines[start:end]) + + +# Range-bearing control forms: addUint8/addInt16/addUint16("name", var, MIN, MAX). +# (addBool/addPin/addSelect/addText/addButton carry no numeric range — skipped.) +_RANGE_CTRL_RE = re.compile( + r'controls_\.add(?:Uint8|Int16|Uint16)\("(?P\w+)"\s*,\s*\w+\s*,\s*' + r'(?P-?\d+)\s*,\s*(?P-?\d+)' +) +# A numeric range as spelled in .md prose: 1–8 / 1-8 / 1 to 8 (en-dash, hyphen, or "to"). +_MD_RANGE_RE = re.compile(r'(-?\d+)\s*(?:[-–]|to)\s*(-?\d+)') + + +def _check_range_drift(source, spec): + """Warn when the .md states a numeric range for a control that CONFLICTS with the + range declared in the .h. Deliberately not "every control must restate its range" + (many legitimately don't — a pin list, an obvious 0–255). We only flag a real + mismatch: the .md line for a control gives a range, and it's a different one.""" + issues = [] + md_lines = spec.splitlines() + for m in _RANGE_CTRL_RE.finditer(source): + name, lo, hi = m.group("name"), int(m.group("lo")), int(m.group("hi")) + # Find the .md line documenting this control (the `- `name` — …` prose line). + for ln in md_lines: + if f"`{name}`" not in ln: + continue + ranges = [(int(a), int(b)) for a, b in _MD_RANGE_RE.findall(ln)] + if ranges and (lo, hi) not in ranges: + issues.append( + f"control '{name}' range in .h is {lo}–{hi}, but the spec line " + f"states {', '.join(f'{a}–{b}' for a, b in ranges)} (drift)") + break # first line mentioning the control is its doc line + return issues + + +# `// Author: … — ` (the em-dash separates the credit from the source URL). +_AUTHOR_URL_RE = re.compile(r'//\s*Author:.*?(https?://\S+)') + + +def _check_author_url_drift(source, spec): + """Warn when a URL in the .h `// Author:` line is absent from the .md (whose + `Origin:` line wraps it in a markdown link). Catches a source/credit URL that + was updated in code but not in the doc. Matches on the bare URL substring.""" + issues = [] + for m in _AUTHOR_URL_RE.finditer(source): + url = m.group(1).rstrip(".,);") # trim trailing punctuation the comment may carry + if url not in spec: + issues.append(f"author URL {url} (from .h) not found in the spec") + return issues + +# Consolidated catalog pages document one module per `### ` block, and each block +# carries its own `source [Foo.h](...)` link (the docs site renders these blocks as +# a table, so a trailing `## Source` list would just duplicate every row's link — +# removed as redundant). These pages are checked by validating those per-block +# source links resolve, NOT by requiring a `## Source` section. +CATALOG_PAGES = { + SPECS / "light" / "effects" / "effects.md", + SPECS / "light" / "modifiers" / "modifiers.md", + SPECS / "light" / "layouts" / "layouts.md", + SPECS / "light" / "drivers" / "drivers.md", +} + + +def _resolve_link(md, href): + """Return an issue string if the relative link doesn't resolve inside the repo, else None.""" + target = href.split("#", 1)[0] # drop any anchor + candidate = (md.parent / target).resolve() + if target.startswith("/") or not candidate.is_relative_to(ROOT): + return f"source link escapes repo or is absolute: {href}" + if not candidate.exists(): + return f"source link does not resolve: {href}" + return None + + def check_source_links(): - """Verify every spec page carries a '## Source' section whose links resolve. + """Verify every spec page's source back-links resolve. - The Source section links each spec back to the .h/.cpp (or UI/asset files) - it documents. This pass catches two drifts the source->spec checks above - can't: a source file renamed/moved out from under a spec's link, and a new - spec page added without a Source section at all. Walks spec->source (the - inverse direction), so it covers every .md including the few that document - multiple files (ui.md, LightConfig.md) with no single matching module. + Catches a source file renamed/moved out from under a spec's link. Two shapes: + - Catalog pages (effects/modifiers/layouts/drivers): validate the per-block + `source [Foo.h](...)` links — one per module, rendered into the table's Links + column. No `## Source` section required (it would duplicate every row). + - Every other spec: require a `## Source` section with ≥1 resolving relative + link (the page's back-reference to the code it documents). Returns a list of (spec_rel, issue) tuples — empty when all is well. """ issues = [] - for md in sorted(SPECS.rglob("*.md")): + for md in sorted(spec_md_files()): spec_rel = md.relative_to(ROOT) text = md.read_text(encoding="utf-8") + + if md in CATALOG_PAGES: + # A catalog page back-references code either directly — each `### ` block + # carries a `source [Foo.h](...)` link (effects/modifiers/layouts) — or + # indirectly, linking each entry to its per-driver detail page which holds + # the real source link (drivers.md, an index). Accept both: validate the + # `source [..]` links if present, else the detail-page `.md` links; every + # relative link must resolve. (No `## Source` section — it would duplicate + # every row; the rows are rendered as a table on the site.) + # \b before `source` so it matches the `source [Foo.h](…)` link marker, + # not the tail of a word like "resource [". + src_links = re.findall(r'\bsource \[[^\]]+\]\(([^)]+)\)', text) + if not src_links: + # index page: the per-entry links to detail pages (…Driver.md), + # same-dir (no ../) — e.g. `](RmtLedDriver.md)`. + src_links = re.findall(r'\]\(([^)]*Driver\.md[^)]*)\)', text) + rel_links = [h for h in src_links if not h.startswith(("http://", "https://"))] + if not rel_links: + issues.append((spec_rel, "no per-block source or detail-page links")) + continue + for href in rel_links: + issue = _resolve_link(md, href) + if issue: + issues.append((spec_rel, issue)) + continue + + # A card-backed detail page (a driver detail page) opens with a back-link to + # its catalog-card section — `[drivers.md § …](drivers.md#…)` — and the source + # link now lives on that card, not repeated here. Validate the back-link + # resolves (so the card is reachable); no `## Source` section required. + back = re.search(r'\]\(([\w./-]*drivers\.md#[\w-]+)\)', text) + if back: + issue = _resolve_link(md, back.group(1)) + issues += ([(spec_rel, issue)] if issue else []) + continue + + # A summary page whose technical reference is the source-generated page + # (moonmodules/{core,light}/moxygen/.md, built by gen_api.py from the + # `.h`'s /// comments) links to that page instead of a `## Source` GitHub blob + # — the generated page *is* the source view. Validate EVERY moxygen link on the + # page (a summary page tables many modules, so it has many such links) against + # the discovered-header set; no `## Source` section required (it would + # duplicate the generated reference). + moxygen_links = re.findall(r'\]\([\w./-]*moxygen/([\w-]+)\.md(?:#[\w-]+)?\)', text) + if moxygen_links: + for stem in moxygen_links: + if stem not in _API_STEMS: + issues.append((spec_rel, f"moxygen link to '{stem}' is not a generated module")) + continue + # Capture only the Source section body — stop at the next top-level # header or end-of-file, so links in a later section aren't mistaken # for source links if a page ever has one after Source. @@ -137,16 +314,9 @@ def check_source_links(): issues.append((spec_rel, "'## Source' section has no relative source link")) continue for href in rel_links: - target = href.split("#", 1)[0] # drop any anchor - # A source link must resolve to a file inside the repo. Relative - # `../` hops are expected (specs are nested under docs/); what's - # rejected is an absolute path or one whose resolved target lands - # outside the repo root. - candidate = (md.parent / target).resolve() - if target.startswith("/") or not candidate.is_relative_to(ROOT): - issues.append((spec_rel, f"source link escapes repo or is absolute: {href}")) - elif not candidate.exists(): - issues.append((spec_rel, f"source link does not resolve: {href}")) + issue = _resolve_link(md, href) + if issue: + issues.append((spec_rel, issue)) return issues def main(): @@ -159,7 +329,13 @@ def main(): rel = mod.relative_to(SRC) spec = find_spec(mod) if not spec: - missing.append(rel) + # docs-v2: a module with no active hand-written .md is still documented if + # it has a source-generated technical page (its .h is in the moxygen + # discovery set). The generated page IS its spec; not a miss. + if mod.stem in _API_STEMS: + ok.append(rel) + else: + missing.append(rel) else: issues = check_spec_freshness(mod, spec) if issues: diff --git a/scripts/docs/build_docs.py b/scripts/docs/build_docs.py new file mode 100644 index 00000000..f053d7bf --- /dev/null +++ b/scripts/docs/build_docs.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "mkdocs-material>=9.5", +# "pymdown-extensions>=10", +# ] +# /// +"""Build (or serve) the projectMM docs site with MkDocs Material. + +Phase 0 of the docs overhaul (docs/backlog/docs-system-overhaul.md): render the +existing docs/ tree as a navigable site, config in mkdocs.yml. Dependencies are +declared inline (PEP 723) so `uv run` provisions them — same pattern as the other +scripts/docs/ tools and the uv-everywhere project rule; no requirements file. + +Link validation is governed by the `validation:` block in mkdocs.yml, not by +--strict. The docs deliberately link OUT to repo files MkDocs can't see +(../src/*.h, ../CLAUDE.md, ../scripts/*) — the "drill into source" links that +resolve in the deployed tree; MkDocs warns on them because they're outside +docs_dir. Those (and the pre-existing stale cross-doc anchors) are `warn`, so +the normal build is the CI gate: it fails on a missing page or broken nav, not +on an out-of-tree source link. --strict is offered for local anchor auditing +only (it promotes every warning to fatal). + +The docs preview serves on :8422 (mkdocs' own default is :8000; we override it). +The three local dev servers use adjacent ports — MoonDeck :8420, installer +preview :8421, docs preview :8422 — so all three run at once without a port +clash. Override with --port. + +Usage: + uv run scripts/docs/build_docs.py # build to site/ (CI gate) + uv run scripts/docs/build_docs.py --serve # live-preview at :8422 + uv run scripts/docs/build_docs.py --serve --port 9000 # serve on a custom port + uv run scripts/docs/build_docs.py --strict # local: fail on ANY warning (anchor audit) +""" + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +CONFIG = ROOT / "mkdocs.yml" +# :8422 (mkdocs' own default is :8000) — adjacent to MoonDeck :8420 and the +# installer preview :8421, so all three local servers run at once (see docstring). +SERVE_PORT = 8422 + + +def _kill_stray_serve() -> None: + """Kill any already-running `mkdocs serve` before starting a new one. + + `mkdocs serve` is a long-running server bound to a fixed port; a leftover + instance (from an earlier run left open, or a crashed session) keeps the + port and makes a fresh serve fail or, worse, silently serve stale content. + Self-clean on start so `--serve` is idempotent however it's launched + (MoonDeck button or terminal). Only serve leaks a process — a plain + `mkdocs build` exits — so this guards the serve path only. Windows uses + taskkill; POSIX uses pkill, matching MoonDeck's _kill_process_by_name.""" + if sys.platform == "win32": + # No cmdline match on Windows taskkill; skip (serve-on-Windows is rare + # in this dev flow, and a bound port surfaces a clear "address in use"). + return + # -f matches the full command line so it hits `mkdocs serve …` specifically, + # not a one-shot `mkdocs build` (which has already exited anyway). + subprocess.run(["pkill", "-f", "mkdocs serve"], capture_output=True) + + +def main() -> int: + ap = argparse.ArgumentParser(description="Build the projectMM docs site.") + ap.add_argument("--strict", action="store_true", + help="Treat warnings (broken nav/links) as errors — used in CI.") + ap.add_argument("--serve", action="store_true", + help="Live-preview the site locally instead of building.") + ap.add_argument("--port", type=int, default=SERVE_PORT, + help=f"Port for --serve (default: {SERVE_PORT}; installer preview owns 8421, MoonDeck 8420).") + ap.add_argument("--site-dir", default=None, + help="Output directory for the built site (default: site/).") + args = ap.parse_args() + + if args.serve: + _kill_stray_serve() + + cmd = ["mkdocs", "serve" if args.serve else "build", "-f", str(CONFIG)] + if args.serve: + # Bind :8422 (overriding mkdocs' :8000 default) so the docs preview and + # the installer preview (:8421) can run side by side. + cmd += ["--dev-addr", f"localhost:{args.port}"] + if args.strict: + cmd.append("--strict") + if args.site_dir and not args.serve: + cmd += ["--site-dir", args.site_dir] + + # Tell the mkdocs hook to stage the installer into the site (local-preview + # single-origin mirror). Serve only — in CI/plain build, release.yml owns + # install/ staging (with the firmware binaries), so the hook must skip it. + env = dict(os.environ) + if args.serve: + env["MM_DOCS_SERVE"] = "1" + + print(f"$ {' '.join(cmd)}") + rc = subprocess.run(cmd, cwd=ROOT, env=env).returncode + + # Closing summary so the URL / next step is the last thing in the log, + # not scrolled off. `serve` runs until interrupted, so this only prints + # after a build (rc reached) or once a serve is stopped. + if rc == 0 and not args.serve: + site = args.site_dir or "site/" + print() + print(f"==> docs built to {site}") + print(f" preview locally: uv run scripts/docs/build_docs.py --serve" + f" → http://localhost:{SERVE_PORT}/projectMM/") + print(f" deployed (after merge to main): https://moonmodules.org/projectMM/") + elif args.serve: + # A serve that just exited: remind where it was. + print() + print(f"==> docs preview was at http://localhost:{args.port}/projectMM/") + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/docs/gen_api.py b/scripts/docs/gen_api.py new file mode 100644 index 00000000..82fc5dec --- /dev/null +++ b/scripts/docs/gen_api.py @@ -0,0 +1,281 @@ +"""Generate per-module technical Markdown from source, at MkDocs build time. + +The source `.h` is the single home of technical content; these pages are generated +*views* of the `///` comments in it — nothing is hand-restated. Pipeline: Doxygen +(the de-facto-standard parser — robust on the C++20 that a Tree-sitter tool choked +on) emits XML; moxygen renders it to Markdown with our custom template +(scripts/docs/moxygen-templates/). Called by mkdocs_hooks.py's on_files, the output +injected into the virtual tree under moonmodules/{core,light}/moxygen/.md — +the domain-nested layout the § Documentation model standard defines. + +**Every** `.h` under src/{core,light} gets a page — core and light, module and +utility alike. Discovery is exhaustive and automatic (no hand-maintained list): a +richly-`///`-commented header yields a full page, a sparsely-commented one a thin +page. Curation is a *separate* layer: only MoonModule subclasses (the things with +controls) appear in the end-user summary tables; a non-module header (a wire-format +struct, a math utility) has no table row but is still reachable as a generated page +and cross-linked from the pages that use it. Catalog effects/modifiers/layouts/ +drivers get a page from their `///` too; their *controls* surface stays in the +summary-page cards, since those come from runtime `controls_.add(...)` calls no +static tool sees. + +Doxygen (a brew/apt binary) and moxygen (via npx) are NOT uv-installable — the one +justified non-uv dependency (like the ESP-IDF Python exception). If either is absent +the generator returns nothing and the site builds without these pages (they appear +in CI, where both are provisioned); a contributor without doxygen still gets the rest. +""" + +import os +import re +import shutil +import subprocess +import tempfile +import xml.etree.ElementTree as ET +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +TEMPLATES = Path(__file__).resolve().parent / "moxygen-templates" / "cpp" + +# A floor on how many pages a healthy run produces. Below this, something broke +# (doxygen parsed nothing, moxygen emitted nothing, the class→header map is empty) — +# raise rather than write a near-empty API set. Set well under the real count (~114 +# today) so it only trips on genuine breakage, not on adding/removing a few headers. +_MIN_EXPECTED_PAGES = 50 + + +class GenApiError(RuntimeError): + """The Doxygen/moxygen toolchain was present but failed or produced too few pages. + Distinct from the toolchain being *absent* (which is a graceful {} skip): this is a + real failure the caller should surface, so CI doesn't ship a degraded docs site.""" + +# The two domains whose headers are offered to Doxygen. The output URI nests under +# the matching domain dir (moonmodules/core/moxygen/, moonmodules/light/moxygen/), +# mirroring src/. Discovery walks these; no per-header list to maintain. +DOMAINS = ("core", "light") + + +def domain_of(header_rel: str) -> str | None: + """The doc domain ('core'/'light') for a repo-relative header path, or None if + the header isn't under src/core or src/light (so it gets no generated page).""" + parts = Path(header_rel).parts + if len(parts) >= 2 and parts[0] == "src" and parts[1] in DOMAINS: + return parts[1] + return None + + +def _discover_headers() -> list[str]: + """Every `.h` under src/core and src/light, repo-relative, sorted. Every header + gets a generated technical page — exhaustive, no gating. Curation (which modules + appear in the end-user summary tables) is the summary pages' job, not the + generator's: only MoonModule subclasses are tabled, but every header is reachable + as a generated page. A sparsely-commented header just yields a thin page.""" + found: list[str] = [] + for d in DOMAINS: + for h in sorted((ROOT / "src" / d).rglob("*.h")): + found.append(str(h.relative_to(ROOT))) + return found + + +def available() -> bool: + """Both tools present? (doxygen binary + npx for moxygen).""" + return shutil.which("doxygen") is not None and shutil.which("npx") is not None + + +def _doxyfile(headers: list[str], xml_out: str) -> str: + # Quote each path so a ROOT (or any parent) containing spaces doesn't get split + # into separate INPUT entries — Doxygen treats a quoted path as one argument. + inputs = " ".join(f'"{ROOT / h}"' for h in headers) + return ( + f'PROJECT_NAME="projectMM API"\n' + f"INPUT = {inputs}\n" + # XML only — moxygen's input. Doxygen defaults GENERATE_HTML *and* + # GENERATE_LATEX to YES; leaving LaTeX on drops a stray latex/ dir of + # .tex/.sty files in the cwd every build. We want neither, just the XML. + f"GENERATE_HTML = NO\nGENERATE_LATEX = NO\nGENERATE_XML = YES\nXML_OUTPUT = {xml_out}\n" + # documented-only, no privates/statics, hide undoc → the compact public surface. + "EXTRACT_ALL = NO\nEXTRACT_PRIVATE = NO\nEXTRACT_STATIC = NO\n" + "HIDE_UNDOC_MEMBERS = YES\nHIDE_UNDOC_CLASSES = YES\n" + "JAVADOC_AUTOBRIEF = YES\n" # a leading `///`/`//` line is the brief + f"STRIP_FROM_PATH = {ROOT}\n" # relative "Defined in src/…", never an abs path + "QUIET = YES\nWARN_IF_UNDOCUMENTED = NO\n" + ) + + +# Where the generated pages are written under docs/ (gitignored). Writing them to +# disk — rather than injecting them as in-memory virtual pages — puts them through +# the standard MkDocs flow (MkDocs discovers real files) and lets a human open/preview +# the .md directly, the same as any other doc source. +DOCS_MOONMODULES = ROOT / "docs" / "moonmodules" + +_BLOB_BASE = "https://github.com/MoonModules/projectMM/blob/main" + + +def _migration_crosscheck_header(header_rel: str, domain: str, stem: str) -> str: + """A TEMPORARY banner prepended to each generated page during the docs-v2 + migration: a link to the source `.h` (GitHub blob — `src/` isn't published to the + site) and, if one still exists, the original hand-written `.md` as an + IN-SITE relative link (that page still builds during the migration, so the link + resolves to its rendered `.html`), so a reviewer can cross-check that the `.md`'s + content was absorbed into the `.h`'s `///` comments. Removed at Stage 5.""" + parts = [f"[source `{Path(header_rel).name}`]({_BLOB_BASE}/{header_rel})"] + # The old per-module .md now lives under docs/moonmodules//archive/. Find + # it by name (excluding the generated moxygen/ dirs); SORT so rglob's unspecified + # order can't make the chosen match (and thus the emitted relative path) vary build + # to build. Link RELATIVE to this generated page so MkDocs resolves it in-site. + this_dir = DOCS_MOONMODULES / domain / "moxygen" + for md in sorted(DOCS_MOONMODULES.rglob(f"{stem}.md")): + if "moxygen" in md.parts: + continue + rel = os.path.relpath(md, this_dir).replace(os.sep, "/") + parts.append(f"[original `{md.name}`]({rel})") + break + return f"> _Migration cross-check (temporary):_ {' · '.join(parts)}\n\n" + + +# A moxygen inter-class link: `](cls_mm-.md#)`, plus the namespace file +# `](cls_mm.md#)` (namespace-level free functions). moxygen names these by its +# OWN per-file output names, which don't exist after we recombine into per-header pages +# — so every such link must be repointed (or dropped for the namespace file, which has +# no single per-header home). +_CLS_LINK_RE = re.compile(r'\]\(cls_(?Pmm(?:-[\w-]+)?)\.md(?P#[\w-]+)?\)') + + +def _rewrite_cls_links(md: str, from_domain: str, cls_to_page: dict) -> str: + """Repoint moxygen's `cls_mm-.md#anchor` cross-links at the per-header page + the class actually lands on. Same domain → a sibling `.md`; cross-domain → + `../..//moxygen/.md`. A class with no generated page (in a class-less + util header) → drop the link, leaving its label as plain text so nothing dangles. + + The `#anchor` fragment is DROPPED: moxygen numbered anchors per its own per-class + file (`#onbuildstate-13`), so after recombining several classes into one page those + numbers no longer match the rendered heading ids — keeping them would emit thousands + of dead-anchor warnings. Linking to the page (no fragment) lands the reader on the + right module; the intra-page jump is a fair trade for a clean build.""" + def _sub(m: re.Match) -> str: + page = cls_to_page.get(m.group("key")) + if page is None: + return "]" # unknown class → strip target, keep the `[label]` text + domain, stem = page + rel = f"{stem}.md" if domain == from_domain else f"../../{domain}/moxygen/{stem}.md" + return f"]({rel})" + return _CLS_LINK_RE.sub(_sub, md) + + +def _class_to_header(xml_dir: Path) -> dict[str, str]: + """Map each moxygen class-file key → its source header (repo-relative), read from + the Doxygen XML `` of every class/struct compound. The key is + moxygen's `--classes` filename stem: the fully-qualified name with `::` → `-` + (e.g. `mm::ControlList` → `mm-ControlList`), matching moxygen's `%s` substitution.""" + mapping: dict[str, str] = {} + for cx in list(xml_dir.glob("class*.xml")) + list(xml_dir.glob("struct*.xml")): + try: + root = ET.parse(cx).getroot() + except ET.ParseError: + continue + cd = root.find("compounddef") + if cd is None: + continue + name = cd.findtext("compoundname") or "" # e.g. "mm::ControlList" + loc = cd.find("location") + if not name or loc is None: + continue + header = loc.get("file") # e.g. "src/core/Control.h" + if header: + mapping[name.replace("::", "-")] = header + return mapping + + +def generate() -> dict[str, str]: + """Write a generated technical page for every documented module under + src/{core,light} into docs/moonmodules/{domain}/moxygen/.md (gitignored), + and return {doc_uri: markdown} for the pages written (empty if the toolchain is + unavailable). doc_uri nests by domain, e.g. 'moonmodules/core/moxygen/Control.md'. + + ONE Doxygen pass over all headers + ONE moxygen `--classes` call — not per-header. + Per-header (132×) meant 132 npx cold-starts (~0.95s each ≈ 150s); the single pass + is ~5s. moxygen `--classes` emits one file per class, so a header's several classes + (Control.h → Control, ControlList, ControlDescriptor) are recombined here into one + per-header page via the class→header map from the XML ``. + + Failure model: `available()` false → return {} (a contributor without the tools + still builds the rest of the site — a *graceful* skip). But if the tools ARE present + and then fail (npx registry fetch error, doxygen crash, empty output), raise + GenApiError — silently returning {} there would ship a docs site with ZERO API pages + and no red X. The caller (mkdocs_hooks) degrades gracefully on absent tools but lets + the error propagate so CI, where the tools are provisioned, fails loudly.""" + if not available(): + return {} + + headers = [h for h in _discover_headers() if domain_of(h)] + if not headers: + return {} + + with tempfile.TemporaryDirectory() as td: + tdp = Path(td) + xml_dir = tdp / "xml" + (tdp / "Doxyfile").write_text(_doxyfile(headers, str(xml_dir))) + r = subprocess.run(["doxygen", str(tdp / "Doxyfile")], + cwd=tdp, capture_output=True, text=True) + if r.returncode != 0 or not xml_dir.exists(): + raise GenApiError(f"doxygen failed (rc={r.returncode}): {r.stderr[-500:]}") + + # One moxygen call, class-per-file (output name = fully-qualified class, ::→-). + m = subprocess.run( + ["npx", "--yes", "moxygen@2.1.10", + "--templates", str(TEMPLATES), "--classes", "--noindex", + "--output", str(tdp / "cls_%s.md"), str(xml_dir)], + cwd=tdp, capture_output=True, text=True, + ) + if m.returncode != 0: + # npx couldn't fetch/run moxygen (registry outage, yanked version, no net). + raise GenApiError(f"npx moxygen failed (rc={m.returncode}): {m.stderr[-500:]}") + + cls_to_header = _class_to_header(xml_dir) + + # moxygen's `--classes` cross-references link to its OWN per-class filenames + # (`cls_mm-.md#anchor`). We recombine classes into per-header pages, so + # those targets don't exist — rewrite each to the header page the class lands + # on. A class in a non-generated header (e.g. a struct in a class-less util) + # maps to nothing → strip the link to plain text so it can't dangle. + # cls-key ("mm-Layer") → (domain, header-stem) of the page it ends up in. + cls_to_page = { + key: (domain_of(h), Path(h).stem) + for key, h in cls_to_header.items() if domain_of(h) + } + + # Group the per-class markdown by owning header (in header order, so a page's + # classes appear top-down as declared). + by_header: dict[str, list[str]] = {} + for cls_md in sorted(tdp.glob("cls_*.md")): + key = cls_md.name[len("cls_"):-len(".md")] # "mm-ControlList" + header = cls_to_header.get(key) + if header is None or domain_of(header) is None: + continue + by_header.setdefault(header, []).append(cls_md.read_text(encoding="utf-8")) + + pages: dict[str, str] = {} + for header, blocks in by_header.items(): + domain = domain_of(header) + stem = Path(header).stem + body = _rewrite_cls_links("".join(blocks), domain, cls_to_page) + md = _migration_crosscheck_header(header, domain, stem) + body + uri = f"moonmodules/{domain}/moxygen/{stem}.md" + dst = DOCS_MOONMODULES / domain / "moxygen" / f"{stem}.md" + dst.parent.mkdir(parents=True, exist_ok=True) + # Write ONLY when the content changed. These files live under docs_dir, + # which `mkdocs serve` watches — an unconditional write bumps the mtime + # every build, which the watcher reads as a change and rebuilds, which + # regenerates, which writes again: an endless rebuild loop that pins the + # serve at ~7s/request. Skipping an identical write leaves mtime untouched, + # so the watcher stays quiet. + if not dst.exists() or dst.read_text(encoding="utf-8") != md: + dst.write_text(md, encoding="utf-8") + pages[uri] = md + + # Tools ran but produced far too few pages → something broke upstream (empty + # XML, an unmatched class→header map). Fail loudly rather than ship a gutted set. + if len(pages) < _MIN_EXPECTED_PAGES: + raise GenApiError( + f"only {len(pages)} API pages generated (expected ≥ {_MIN_EXPECTED_PAGES}) " + f"— doxygen/moxygen ran but produced almost nothing") + return pages diff --git a/scripts/docs/mkdocs_hooks.py b/scripts/docs/mkdocs_hooks.py new file mode 100644 index 00000000..2bb7d289 --- /dev/null +++ b/scripts/docs/mkdocs_hooks.py @@ -0,0 +1,367 @@ +"""MkDocs build hooks. Wired via `hooks:` in mkdocs.yml. Three jobs: + +1. on_files — synthesise `tests/unit-tests.md` + `tests/scenario-tests.md` into + MkDocs' virtual file tree from the test files (via `_test_metadata.py`, the + same parser the CLI generator + MoonDeck use). So the inventory pages are NOT + committed to the repo and can't drift — rebuilt from source every build. The + per-effect `[Tests]` links in the catalog pages point at these pages; kept as + plain one-line links so the effect cards stay compact (a link, not a case dump). + +2. on_page_markdown — repoint links that escape docs/ into repo files + (`../src/*.h`, `../CLAUDE.md`, `../scripts/*`) to absolute GitHub blob URLs, + since the site can't host `src/` (pre-Doxide) and a relative link would 404. + +3. on_post_build (serve-only) — stage the web installer into the built site under + /install/ so the local preview mirrors production's single-origin layout. +""" + +import os +import re +from pathlib import Path + +# scripts/docs/ is on sys.path when mkdocs runs from repo root; import the shared parser. +import sys +_HERE = Path(__file__).resolve().parent +if str(_HERE) not in sys.path: + sys.path.insert(0, str(_HERE)) + +from _test_metadata import ( # noqa: E402 + ROOT, + collect_unit_files, + collect_scenario_files, +) +from generate_test_docs import render_unit_tests, render_scenarios # noqa: E402 +import gen_api # noqa: E402 (same dir, on sys.path via _HERE above) + + +# ---- source-link rewrite: docs link OUT to repo files the site can't host ---- + +# The blob base for links that escape docs/ into the repo (src/, scripts/, test/, +# CLAUDE.md, README.md, web-installer/). The site can't serve these — src/ isn't +# published — so a relative link 404s on the deployed site. Rewrite to an absolute +# GitHub blob URL so "source [Foo.h]" resolves everywhere (locally-served preview + +# moonmodules.org). A `.h` link for a module that HAS a generated technical page is +# repointed at that in-site page instead (see _API_MODULES / on_page_markdown); only +# files with no generated page fall through to the GitHub blob URL. +_BLOB_BASE = "https://github.com/MoonModules/projectMM/blob/main" + +# {module stem: domain} for every module that got a generated technical page this +# build, e.g. {"Control": "core", "FireEffect": "light"} — populated by on_files, +# read by the link retargeter to build the domain-nested target path. Empty when the +# doxygen/moxygen toolchain is absent (so links fall back to GitHub, unchanged). +_API_MODULES: dict[str, str] = {} + +# Repo top-level dirs/files a doc may link into but the site doesn't host. +_OUT_OF_DOCS = ("src/", "scripts/", "test/", "esp32/", "web-installer/", + "CLAUDE.md", "README.md", "library.json") + +# A markdown link whose target starts with one or more `../` then an out-of-docs path. +_OUT_LINK_RE = re.compile(r'\]\((?P(?:\.\./)+(?P[^)#]+)(?P#[^)]*)?)\)') + + +def _rewrite_out_of_docs_links(markdown: str, src_uri: str) -> str: + """Rewrite links that climb out of docs/ into repo files → absolute GitHub blob + URLs, resolved against the page's own location so any `../` depth is handled. + src_uri is the page's path under docs/ (e.g. 'moonmodules/light/effects/effects.md').""" + # The page's directory within docs/ — the anchor `../` hops resolve against. + page_dir = Path("docs") / src_uri + page_dir = page_dir.parent + + # Depth from this page up to docs/ (so a `.h` link can be repointed at the + # in-site generated API page with the right relative `../` count). + up = "../" * len(Path(src_uri).parent.parts) if str(Path(src_uri).parent) != "." else "" + + def _sub(m: re.Match) -> str: + href = m.group("href") + frag = m.group("frag") or "" + rel = href.split("#", 1)[0] + # A `.h` whose module has a generated technical page → point at the in-site + # page, not GitHub. The generated reference IS the source view we want. The + # target nests by domain (core/light), so use the recorded domain. + stem = Path(rel).stem + if rel.endswith(".h") and stem in _API_MODULES: + domain = _API_MODULES[stem] + return f"]({up}moonmodules/{domain}/moxygen/{stem}.md{frag})" + # Resolve against the page dir to a repo-relative path (the correct case). + target = os.path.normpath(str(page_dir / rel)).replace(os.sep, "/") + if target.startswith(_OUT_OF_DOCS) and not target.startswith("docs/"): + return f"]({_BLOB_BASE}/{target}{frag})" + # Fallback for links authored relative to the REPO ROOT (common in the + # transient history/plans + backlog notes, written before their file sat + # this deep under docs/): strip leading `../` and see if the remainder is + # itself an out-of-docs repo path. Catches `../../scripts/x.py` from a + # docs/history/plans/ page, which resolves to a nonexistent docs/scripts/x.py. + stripped = re.sub(r'^(?:\.\./)+', '', rel) + if stripped.startswith(_OUT_OF_DOCS): + return f"]({_BLOB_BASE}/{stripped}{frag})" + return m.group(0) # in-docs link (incl. ../ into another docs page) — leave it + + return _OUT_LINK_RE.sub(_sub, markdown) + + +# ---- MkDocs hooks ---- + +# ---- catalog-page table transform: render prose ### blocks as a MoonLight-style table ---- + +# The consolidated catalog pages whose ### effect/modifier/layout blocks are rendered +# as a table (source stays authored as readable prose blocks; the table is build-time). +_CATALOG_PAGES = { + "moonmodules/light/effects/effects.md", + "moonmodules/light/modifiers/modifiers.md", + "moonmodules/light/layouts/layouts.md", + "moonmodules/light/drivers/drivers.md", + # Summary pages for the non-catalog module groups use the same ### -block → table + # transform, one common authoring process for every summary page (supporting rows + # just leave the preview/controls columns blank). + "moonmodules/core/supporting/supporting.md", + "moonmodules/core/ui/ui.md", + "moonmodules/light/supporting/supporting.md", +} + +_H3_RE = re.compile(r'^###\s+(?P.+?)\s*$') +_H2_RE = re.compile(r'^##\s+') # any level-2 heading = section boundary +_ANCHOR_RE = re.compile(r'^<a id="(?P<id>[^"]+)"></a>\s*$') +_IMG_RE = re.compile(r'^<img\b.*?>\s*$') +_PARAM_RE = re.compile(r'^-\s+') +_ORIGIN_RE = re.compile(r'^Origin:\s*(?P<body>.+?)\s*$') +_TESTS_RE = re.compile(r'^\[Tests\]\((?P<href>[^)]+)\)\s*$') +_DETAIL_RE = re.compile(r'^Detail:\s*(?P<body>.+?)\s*$') # `Detail: [Foo.md](..) · …` → Links column +_DETAILS_RE = re.compile(r'^##\s+(?P<name>.+?)\s+—\s+details\s*$') + + +def _cell(s: str) -> str: + """A markdown-table cell: collapse newlines to <br> so multi-line content + survives inside a single pipe cell.""" + return s.replace("\n", "<br>") + + +def _slug(text: str) -> str: + """The heading anchor id MkDocs generates for a `## Heading`, so a hand-built + `#anchor` link resolves. Delegates to Python-Markdown's OWN toc slugify (the exact + function MkDocs' toc uses) rather than re-implementing it — a hand-rolled mimic + drifts on unicode (accent-stripping), consecutive separators, and decoration. + e.g. 'LED output — details' -> 'led-output-details'. `markdown` is always present + in the build env (it's a MkDocs dependency).""" + from markdown.extensions.toc import slugify + return slugify(text, "-") + + +def _emit_row(b: dict, details_names: set) -> str: + """One 4-column table row for a parsed ### block.""" + # Col 1: anchored name (so a help #anchor lands on the row) + description. A + # merged card (e.g. the LED-output drivers) carries several ids so each old + # per-driver anchor still resolves onto the one row. + anchor_spans = "".join(f'<span id="{a}"></span>' for a in b.get("anchors", [])) + # Name in a distinct styled span, description below in a muted span — so the two + # read as title + subtitle rather than one run-on line (styled in extra.css). + name = f'{anchor_spans}<span class="mm-name">{b["title"]}</span>' + desc = " ".join(b["desc"]) + col1 = _cell(f'{name}<br><span class="mm-desc">{desc}</span>' if desc else name) + + # Col 2: GIF/image (strip the hand-authored width/height so CSS sizes it to + # fill the column), or a placeholder. + if b["img"]: + img = re.sub(r'\s+(width|height)="[^"]*"', "", b["img"]) + col2 = img.replace("<img ", '<img class="mm-preview" ', 1) + else: + # No image: a neutral dash reads correctly both for a catalog module still + # awaiting a gif and a supporting module that has no visual by nature. + col2 = "—" + + # Col 3: parameters, one per line. Split each at the em-dash into the name part + # (before — keeps its `code` chips, styled accent) and the description (after — + # greyed via .mm-pdesc, matching the muted module description). Params without a + # dash render whole in the name part. + col3_parts = [] + for p in b["params"]: + p = re.sub(r'^-\s+', '', p) + head, sep, tail = p.partition(" — ") + if sep: + col3_parts.append(f'<span class="mm-param">{head}' + f' — <span class="mm-pdesc">{tail}</span></span>') + else: + col3_parts.append(f'<span class="mm-param">{p}</span>') + col3 = "".join(col3_parts) if col3_parts else "—" + + # Col 4: everything a reader clicks OUT to — so the description column is pure + # prose. Tests + detail-page link(s) + source/attribution + a ⌄ details anchor. + links = [] + if b["tests"]: + links.append(f"[Tests]({b['tests']})") + if b["detail"]: + links.append(b["detail"]) # the `Detail: [Foo.md](..) · …` line + if b["origin"]: + links.append(b["origin"]) # carries `source [Foo.h](...)` + attribution + # The module's display name without the trailing ` 💫 · dim` decoration, e.g. + # "LED output 💫 · wire" -> "LED output". A `## <that name> — details` section + # below the table (if present) is linked as `⌄ details`, anchored to MkDocs' + # own slug for that heading (`<name> — details` -> `<name>-details`). + name = re.split(r'\s+[💫🦅🐙📊🌙⚡️·]', b["title"])[0].strip() + if name in details_names: + links.append(f"[⌄ details](#{_slug(name + ' — details')})") + # Wrap so the plain attribution text greys (.mm-links); the actual links keep + # their accent link colour (Material's `a` styling wins over the grey). + col4 = f'<span class="mm-links">{_cell("<br>".join(links))}</span>' if links else "—" + + return f"| {col1} | {col2} | {col3} | {col4} |" + + +def _render_catalog_table(markdown: str) -> str: + """Render each run of consecutive `### ` blocks as a 4-column table, section by + section. Everything else — the page H1/lead, `## Group` headers, the trailing + `## Source` list, `## <Name> — details` sections, stray prose between blocks — + passes through verbatim, so a table only ever contains genuine module blocks + (a `##` heading closes the current table). Source .md stays authored as prose.""" + lines = markdown.split("\n") + details_names = {_DETAILS_RE.match(l).group("name") for l in lines if _DETAILS_RE.match(l)} + + out = [] + rows = [] # accumulated table rows for the current run + cur = None # block being parsed + pending_anchors = [] # <a id> lines seen before the next ### (a merged card can carry several) + + def flush_block(): + nonlocal cur + if cur is not None: + rows.append(_emit_row(cur, details_names)) + cur = None + + def flush_table(): + """Emit the accumulated rows as one wrapped table, then reset.""" + if rows: + out.append('<div class="mm-catalog-wrap" markdown="1">') + out.append("") + out.append("| Name | Preview | Parameters | Links |") + out.append("|------|---------|------------|-------|") + out.extend(rows) + out.append("") + out.append("</div>") + out.append("") + rows.clear() + + for ln in lines: + if _ANCHOR_RE.match(ln): + pending_anchors.append(_ANCHOR_RE.match(ln).group("id")) + continue + if _H3_RE.match(ln): # start a new block (same table run) + flush_block() + cur = {"anchors": pending_anchors, "title": _H3_RE.match(ln).group("title"), + "desc": [], "img": None, "params": [], "origin": None, + "tests": None, "detail": None} + pending_anchors = [] + continue + if _H2_RE.match(ln): # section boundary: close block + table + flush_block() + flush_table() + for a in pending_anchors: # stray anchors before a ## — keep them + out.append(f'<a id="{a}"></a>') + pending_anchors = [] + out.append(ln) + continue + if cur is not None: # inside a block: classify the line + if _IMG_RE.match(ln): + cur["img"] = ln.strip() + elif _PARAM_RE.match(ln): + cur["params"].append(ln.strip()) + elif _ORIGIN_RE.match(ln): + cur["origin"] = _ORIGIN_RE.match(ln).group("body") + elif _TESTS_RE.match(ln): + cur["tests"] = _TESTS_RE.match(ln).group("href") + elif _DETAIL_RE.match(ln): + cur["detail"] = _DETAIL_RE.match(ln).group("body") + elif ln.strip(): + cur["desc"].append(ln.strip()) + # blank line inside a block is a separator — ignore + continue + # outside any block and not a heading: passthrough (intro, prose, Source items) + out.append(ln) + + flush_block() + flush_table() + for a in pending_anchors: + out.append(f'<a id="{a}"></a>') + return "\n".join(out) + + +def on_files(files, config): + """Inject build-time-generated pages into the virtual tree: the test inventories, + and (Phase 4b) a per-module API reference for each infrastructure module, + generated from its `///` source comments by gen_api (Doxygen → moxygen).""" + from mkdocs.structure.files import File + + def _add(src_uri: str, content: str): + f = File.generated(config, src_uri, content=content) + existing = files.get_file_from_path(src_uri) # replace a same-URI file if any + if existing: + files.remove(existing) + files.append(f) + + _add("tests/unit-tests.md", render_unit_tests(collect_unit_files())) + _add("tests/scenario-tests.md", render_scenarios(collect_scenario_files())) + + # Source-generated technical pages. gen_api.generate() writes each to disk under + # docs/moonmodules/<domain>/moxygen/<Module>.md (gitignored, so a human can preview + # the .md directly) and returns {uri: markdown}. We still inject via _add so THIS + # build sees them (MkDocs' file scan ran before this hook, so the fresh writes + # aren't in `files` yet; _add de-dups a same-URI entry a later scan would find). + # Empty dict when doxygen/npx are absent (local without the toolchain) — the site + # still builds; the pages appear in CI. But if the tools ARE present and fail (npx + # fetch error, doxygen crash, near-empty output), generate() raises GenApiError, + # which propagates and fails the build — so a transient failure can't silently ship + # a docs site with zero API pages. Record {stem: domain} so on_page_markdown can + # retarget a `.h` link to the domain-nested page. + global _API_MODULES + api_pages = gen_api.generate() + # uri: 'moonmodules/<domain>/moxygen/<Stem>.md' → {'<Stem>': '<domain>'} + _API_MODULES = { + Path(uri).stem: Path(uri).parts[1] for uri in api_pages + } + for uri, md in api_pages.items(): + _add(uri, md) + return files + + +def on_page_markdown(markdown, page, config, files): + """Repoint out-of-docs source links to GitHub blob URLs, then (on the catalog + pages) render the prose ### blocks as a MoonLight-style 4-column table. Source + .md stays authored as readable blocks; the table is build-time only.""" + markdown = _rewrite_out_of_docs_links(markdown, page.file.src_uri) + if page.file.src_uri in _CATALOG_PAGES: + markdown = _render_catalog_table(markdown) + return markdown + + +def on_post_build(config): + """LOCAL PREVIEW ONLY: stage the web installer into the built site under + /install/, so the local docs preview mirrors the SINGLE-ORIGIN production + layout (docs at /, installer at /install/). Without it the docs preview + (:8422) 404s on the Flash link's /projectMM/install/ target — the installer + preview is a separate server on :8421, and a production-relative link can't + reach it. Copying it in makes every /install/ link resolve locally exactly + as deployed. + + Gated to serve mode via MM_DOCS_SERVE (set by build_docs.py --serve): in CI, + release.yml does its OWN, more complete install/ staging (incl. the + releases/<tag>/ firmware binaries), so this must NOT run there and risk + overlaying the binary-less repo copy over it. Mirrors release.yml's + `cp -r web-installer/. pages/install/` + the install-picker*.js + library.json + siblings. Best-effort: absent files are skipped.""" + import os + import shutil + + if os.environ.get("MM_DOCS_SERVE") != "1": + return # CI / plain build — release.yml owns install/ staging + + site = Path(config["site_dir"]) + dst = site / "install" + src = ROOT / "web-installer" + if not src.is_dir(): + return + dst.mkdir(parents=True, exist_ok=True) + shutil.copytree(src, dst, dirs_exist_ok=True) + # The picker's JS halves live in src/ui/ (embedded in firmware too); CI stages + # them beside the installer. library.json feeds the installer its version. + for extra in ("src/ui/install-picker.js", "src/ui/install-picker-boards.js", "library.json"): + p = ROOT / extra + if p.is_file(): + shutil.copy(p, dst / p.name) diff --git a/scripts/docs/moxygen-templates/cpp/class.md b/scripts/docs/moxygen-templates/cpp/class.md new file mode 100644 index 00000000..608ea57e --- /dev/null +++ b/scripts/docs/moxygen-templates/cpp/class.md @@ -0,0 +1,45 @@ +{{cleanAnchor refid name}} + +## {{shortname name}} + +```cpp +{{classSignature}} +``` +{{#if (sourceLabel)}} <small>{{sourceLabel}}</small>{{/if}} +{{#if basecompoundref}}> **Inherits:** {{#each basecompoundref}}{{linkedName name refid}}{{#unless @last}}, {{/unless}}{{/each}} +{{/if}} + +{{briefdescription}} + +{{detaileddescription}} + +{{#each inheritedMemberGroups}} +### Inherited from {{linkedName name refid}} + +{{#each members}}- `{{kind}}` {{linkedName name refid}}{{#if (memberSummary this)}} — {{cell (memberSummary this)}}{{/if}} +{{/each}} + +{{/each}} +{{! Public API only: skip the private/protected member sections (`section` is the raw + Doxygen kind, a stabler discriminator than the English label). A technical + reference documents the surface a caller uses, not internals. moxygen registers + only 2-arg `eq`/`or`, so this is an explicit denylist of the eight private/ + protected kinds from moxygen's SECTION_LABELS. }} +{{#each filtered.sections}} +{{#unless (or (or (or (eq section "private-func") (eq section "private-static-func")) (or (eq section "private-attrib") (eq section "private-static-attrib"))) (or (or (eq section "private-slot") (eq section "protected-func")) (or (eq section "protected-attrib") (eq section "protected-slot"))))}} +### {{label}} + +{{#each members}} +`{{#if returnTypeShort}}{{returnTypeShort}} {{/if}}{{signature}}`{{#if (memberSummary this)}} +: {{cell (memberSummary this)}}{{/if}} +{{#if enumvalue}} + +| Value | Description | +|-------|-------------| +{{#each enumvalue}}| `{{name}}` | {{summary}} | +{{/each}} +{{/if}} + +{{/each}} +{{/unless}} +{{/each}} diff --git a/scripts/docs/moxygen-templates/cpp/index.md b/scripts/docs/moxygen-templates/cpp/index.md new file mode 100644 index 00000000..837684da --- /dev/null +++ b/scripts/docs/moxygen-templates/cpp/index.md @@ -0,0 +1,35 @@ +# API Reference + +{{#if filtered.compounds}} +| Name | Description | +|------|-------------| +{{#each filtered.compounds}}| [`{{shortname name}}`](#{{cleanId refid name}}) | {{cell summary}} | +{{/each}} +{{/if}} + +{{#each filtered.sections}} +## {{label}} + +{{#each members}} + +{{cleanAnchor refid name}} + +#### {{name}} + +```cpp +{{signature}} +``` + +{{briefdescription}} + +{{#if enumvalue}} +| Value | Description | +|-------|-------------| +{{#each enumvalue}}| `{{name}}` | {{summary}} | +{{/each}} +{{/if}} + +{{detaileddescription}} + +{{/each}} +{{/each}} diff --git a/scripts/docs/moxygen-templates/cpp/namespace.md b/scripts/docs/moxygen-templates/cpp/namespace.md new file mode 100644 index 00000000..40d735d6 --- /dev/null +++ b/scripts/docs/moxygen-templates/cpp/namespace.md @@ -0,0 +1,100 @@ +{{cleanAnchor refid name}} + +# {{shortname name}} + +{{#if (eq kind "group")}} +{{summary}} +{{else}} +{{briefdescription}} + +{{detaileddescription}} +{{/if}} + +{{#with (compoundsOfKind filtered.compounds "namespace") as |namespaces|}} +{{#if namespaces}} +### Namespaces + +| Name | Description | +|------|-------------| +{{#each namespaces}}| {{linkedName name refid}} | {{cell summary}} | +{{/each}} +{{/if}} +{{/with}} + +{{#with (compoundsOfKind filtered.compounds "class" "struct" "interface") as |types|}} +{{#if types}} +### Classes + +| Name | Description | +|------|-------------| +{{#each types}}| {{linkedName name refid}} | {{cell summary}} | +{{/each}} +{{/if}} +{{/with}} + +{{#with (compoundsOfKind filtered.compounds "enum") as |enums|}} +{{#if enums}} +### Enumerations + +| Name | Description | +|------|-------------| +{{#each enums}}| {{linkedName name refid}} | {{cell summary}} | +{{/each}} +{{/if}} +{{/with}} + +{{#each filtered.sections}} +### {{label}} + +{{#if (hasReturnColumn section)}} +| Return | Name | Description | +|--------|------|-------------| +{{#each members}}| {{returnTypeShort}} | [`{{name}}`](#{{cleanId refid name}}) {{badges}} | {{cell (memberSummary this)}} | +{{/each}} +{{else}} +| Name | Description | +|------|-------------| +{{#each members}}| [`{{name}}`](#{{cleanId refid name}}) {{badges}} | {{cell (memberSummary this)}} | +{{/each}} +{{/if}} + +{{#each members}} + +--- + +{{cleanAnchor refid name}} + +#### {{name}} + +{{badges}} + +```cpp +{{signature}} +``` + +{{briefdescription}} + +{{detaileddescription}} + +{{#unless briefdescription}} +{{#unless detaileddescription}} +{{memberSummary this}} +{{/unless}} +{{/unless}} + +{{#if (hasDocumentedParams params)}} +| Parameter | Type | Description | +|-----------|------|-------------| +{{#each (documentedParams params)}}| `{{name}}` | `{{type}}` | {{description}} | +{{/each}} +{{/if}} + +{{#if enumvalue}} +| Value | Description | +|-------|-------------| +{{#each enumvalue}}| `{{name}}` | {{summary}} | +{{/each}} +{{/if}} + +{{/each}} +{{/each}} diff --git a/scripts/docs/moxygen-templates/cpp/page.md b/scripts/docs/moxygen-templates/cpp/page.md new file mode 100644 index 00000000..18cb5381 --- /dev/null +++ b/scripts/docs/moxygen-templates/cpp/page.md @@ -0,0 +1,14 @@ +# {{shortname name}} {{cleanAnchor refid name}} + +{{briefdescription}} + +{{detaileddescription}} + +{{#if filtered.members}} +## Contents + +| Section | +|---------| +{{#each filtered.members}}| [`{{name}}`](#{{cleanId refid name}}) | +{{/each}} +{{/if}} diff --git a/scripts/moondeck.py b/scripts/moondeck.py index 046b745d..991fe43a 100644 --- a/scripts/moondeck.py +++ b/scripts/moondeck.py @@ -44,11 +44,11 @@ def _app_version(): # Boards catalog (single source of truth, shared with the web installer) # --------------------------------------------------------------------------- -BOARDS_FILE = ROOT / "docs" / "install" / "deviceModels.json" +BOARDS_FILE = ROOT / "web-installer" / "deviceModels.json" def _load_boards(): - """Load docs/install/deviceModels.json. Returns [] on missing/malformed file — + """Load web-installer/deviceModels.json. Returns [] on missing/malformed file — `_deduce_board` then always returns "" (no firmware uniquely identifies a board), MoonDeck JS shows only the empty default. The web installer Step 2 picker will share this file. @@ -61,11 +61,11 @@ def _load_boards(): BOARDS = _load_boards() -FIRMWARES_FILE = ROOT / "docs" / "install" / "firmwares.json" +FIRMWARES_FILE = ROOT / "web-installer" / "firmwares.json" def _load_firmwares(): - """Shipping firmware-variant names from docs/install/firmwares.json — the + """Shipping firmware-variant names from web-installer/firmwares.json — the generated projection of build_esp32's FIRMWARES dict (the single source of truth, shared with the CI release matrix). Returns [] on missing/malformed file, so the MoonDeck UI just shows no firmware entries. Filtering on @@ -182,7 +182,7 @@ def _deduce_board(firmware: str) -> str: """Firmware → board name when exactly one catalog entry claims this firmware. Returns "" when zero (unknown firmware) or multiple boards claim it (ambiguous — user picks). Catalog lives at - docs/install/deviceModels.json; see docs/architecture.md § Firmware vs board. + web-installer/deviceModels.json; see docs/architecture.md § Firmware vs board. """ if not firmware: return "" @@ -193,7 +193,7 @@ def _deduce_board(firmware: str) -> str: def _push_board_to_device(ip: str, board: str) -> bool: """POST /api/control on the device for every per-board control in deviceModels.json. - For boards that have a catalog entry in docs/install/deviceModels.json: fans + For boards that have a catalog entry in web-installer/deviceModels.json: fans out the full `controls.<Module>.<control>` block (matching the web installer's and the device-side `?deviceModel=` Inject path — same generic iteration, so adding a new field to a board entry Just Works without @@ -910,7 +910,7 @@ def do_GET(self): self._send_json({"modules": test_meta.list_test_modules()}) elif self.path == "/api/boards": - # Serves docs/install/deviceModels.json (loaded at startup). The web + # Serves web-installer/deviceModels.json (loaded at startup). The web # installer (Step 2) will fetch the same file directly from # Pages; MoonDeck reads it locally and exposes it here so the # JS UI shares one source of truth with the Python deduce path. @@ -1213,6 +1213,11 @@ def _handle_run(self, script_id: str, params: dict): script_path = SCRIPTS_DIR / script_def["script"] cmd = ["uv", "run", str(script_path)] + # Fixed args a card always passes to its script (e.g. build_docs runs + # with `--serve`). Distinct from `flags` (user checkboxes) and the + # `needs_*` selectors (UI-driven values) — these are constant per card. + cmd.extend(script_def.get("args", [])) + # Forward selector state (firmware / port / host) when the script # declares it needs them. The UI maintains a single Firmware dropdown # on the ESP32 tab driving every needs_firmware script; the older diff --git a/scripts/moondeck_config.json b/scripts/moondeck_config.json index 55e38545..5443f15a 100644 --- a/scripts/moondeck_config.json +++ b/scripts/moondeck_config.json @@ -125,6 +125,17 @@ "help": "update_module_docs", "script": "docs/update_module_docs.py" }, + { + "id": "build_docs", + "tab": "pc", + "group": "docs", + "label": "Preview Docs Site", + "help": "build_docs", + "script": "docs/build_docs.py", + "args": ["--serve"], + "long_running": true, + "process_name": "mkdocs" + }, { "id": "live_scenario", "tab": "live", diff --git a/scripts/moondeck_ui/app.js b/scripts/moondeck_ui/app.js index 8964e25a..200696df 100644 --- a/scripts/moondeck_ui/app.js +++ b/scripts/moondeck_ui/app.js @@ -13,7 +13,7 @@ let firmwares = []; let scenarios = []; // [{name, module, also}] let testModules = []; // ["CamelCaseName", ...] // Boards catalog loaded from /api/boards (served by moondeck.py from -// docs/install/deviceModels.json). Replaces the previously-hardcoded boardOptions +// web-installer/deviceModels.json). Replaces the previously-hardcoded boardOptions // list — same file the web installer (Step 2) will fetch directly from // GitHub Pages. Empty until init() loads it; renderDevices waits on init. let boards = []; // [{ key, label, firmwares: [...] }] (firmwares[0] is the default) diff --git a/scripts/run/preview_installer.py b/scripts/run/preview_installer.py index 5e8fcb00..29485b10 100644 --- a/scripts/run/preview_installer.py +++ b/scripts/run/preview_installer.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Locally preview the web installer at docs/install/index.html. +"""Locally preview the web installer at web-installer/index.html. Stages a small directory (the install page + the shared install-picker module) and serves it with `python -m http.server`, plus the @@ -12,7 +12,7 @@ - **render-only** (no `build/esp32-*/projectMM.bin` present): the picker populates against the real GitHub Releases API, dropdowns work, but clicking Install fails because the local server has no - `releases/` tree. Equivalent to "Recipe A" in docs/install/README.md. + `releases/` tree. Equivalent to "Recipe A" in web-installer/README.md. Useful for iterating on HTML/CSS/JS without tagging a release. - **flash-ready** (at least one local ESP32 build exists): the @@ -26,7 +26,7 @@ The flash-ready mode is the developer's test ground for the install flow before deploying to GitHub Pages: any change to the installer page or install-picker.js can be verified against a real ESP32 over -`http://localhost:8000/` (Web Serial works on localhost without the +`http://localhost:8421/` (Web Serial works on localhost without the secure-origin requirement that gates the public site). Long-running. MoonDeck shows a Stop button while this is up; pressing @@ -43,7 +43,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent.parent -INSTALL_DIR = ROOT / "docs" / "install" +INSTALL_DIR = ROOT / "web-installer" ASSETS_BOARDS_DIR = ROOT / "docs" / "assets" / "boards" PICKER_JS = ROOT / "src" / "ui" / "install-picker.js" # Board-catalog / chip-detection half of the picker — web-installer only (not @@ -72,7 +72,7 @@ # release exists yet the default lands on `latest`, which works.) LOCAL_TAG = "latest" LOCAL_VERSION = "local-dev" -PORT = 8000 +PORT = 8421 def _stage_runtime_files(src_dir: Path, dst_dir: Path): @@ -117,7 +117,7 @@ def stage_install_page(): Layout matches Pages exactly: the installer under /install/, board images under /install/assets/boards/, the releases tree under /install/releases/. A root index.html redirects / → /install/ so the historical - `localhost:8000/` entry point still lands on the installer. The shared + `localhost:8421/` entry point still lands on the installer. The shared install-picker.js (under src/ui/, shared with the on-device UI) is copied in. """ if STAGE_DIR.exists(): diff --git a/src/core/AudioModule.h b/src/core/AudioModule.h index 7d3b87cc..54d1eb8b 100644 --- a/src/core/AudioModule.h +++ b/src/core/AudioModule.h @@ -1,28 +1,70 @@ #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. +/// Acquires an audio source and publishes an `AudioFrame` — an overall sound +/// **level**, a 16-band frequency **spectrum**, and the **dominant peak** — as the +/// producer half of the audio-reactive pipeline +/// +/// 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. `AudioVolumeEffect` and `AudioSpectrumEffect` are the consumers, +/// reaching the live frame through the static `latestFrame()`. +/// +/// **Named for what it does** — audio acquisition plus analysis, not for one +/// source. Today the source is a digital I2S MEMS microphone (INMP441-class, the +/// only one wired); the same source-independent analysis pipeline is built to serve +/// other sources (line-in, USB audio, PDM mics, I2C codecs) 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. +/// +/// **User-added Peripheral.** A SystemModule Peripheral child, registered in the +/// factory and added through the UI when wanted, not boot-wired — auto-wiring it +/// forced an I2S init on every board, which on the classic ESP32 hung `setup()` and +/// boot-looped a mic-less device. When added, its pins default to unset (−1, the +/// standard Pin-control sentinel, so GPIO 0 stays a usable mic pin) and it stays +/// idle with a status note until the user enters the real GPIOs. Chip-agnostic: +/// gated on `platform::hasI2sMic`, inert with a status note on targets without I2S +/// and on desktop. +/// +/// **The AudioFrame pipeline.** Each `loop()` that completes a block: 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. The DSP choices are textbook defaults on +/// purpose — a Hann window, RMS for level, a geometric band split, argmax for the +/// peak — with deliberately no per-frequency correction table (the INMP441 is flat +/// ±3 dB across the range that matters). The level is overall RMS loudness computed +/// independently of the FFT, not derived from the bands. +/// +/// **Hardware: INMP441-class digital mic.** A self-clocked I2S MEMS microphone: +/// standard/Philips framing, 24-bit data left-justified in a 32-bit slot, mono. The +/// part is self-clocked from the bit clock; there is no master-clock (MCLK) pin. +/// The bench wiring is WS=4 (word-select/LRCLK), SD=5 (serial data out), SCK=6 (bit +/// clock). It drives the one slot its L/R select pin chooses (tie L/R to GND for the +/// left slot); if `level` stays at the floor with sound present, the mic is filling +/// the other slot — one wire, not firmware. +/// +/// **Platform seams.** Only the I2S read and the FFT kernel are platform code +/// (`platform_esp32_i2s.cpp`: IDF's `i2s_std` driver + esp-dsp's float +/// `dsps_fft2r_fc32`, the radix-2 real FFT); everything else is plain domain math +/// that runs in CI on the desktop's reference DFT. The signal math is host-tested +/// domain code (`AudioLevel.h`, `AudioBands.h`); this module owns the lifecycle, +/// the controls, and the two seams. +/// +/// **Hot path:** fixed member scratch buffers (sample block + window + magnitudes, +/// ~6 KB DRAM-resident), one float FFT per loop, no per-loop heap. The mic read is +/// non-blocking (the first ~250 ms of power-on settling garbage flows through the +/// first few reads and self-corrects); a bad init leaves the module idle (zeroed +/// frame), never crashing. +/// +/// **Prior art:** audio-reactive lighting is a long-standing idea in the +/// LED-controller world (WLED-MM and MoonLight are the closest lineage). This is +/// projectMM's own implementation, designed from the INMP441 datasheet +/// (https://invensense.tdk.com/wp-content/uploads/2015/02/INMP441.pdf) and standard +/// DSP rather than traced from any one project. See +/// docs/moonmodules/core/AudioModule.md for the full DSP rationale, the source-seam +/// backlog (line-in / PDM / analog / I2C codecs), and the forward-looking adaptive +/// noise-gate analysis. #include "core/MoonModule.h" #include "core/AudioFrame.h" @@ -39,21 +81,21 @@ 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. + /// 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 + static constexpr size_t kMag = kBlock / 2; ///< real-FFT magnitude bins ModuleRole role() const override { return ModuleRole::Peripheral; } - // Unlike a zero-cost diagnostic peripheral, 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.) + /// Unlike a zero-cost diagnostic peripheral, 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 (-1, the standard Pin- @@ -61,29 +103,31 @@ class AudioModule : public MoonModule { // 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. --- - int8_t wsPin = -1; // word-select / LRCLK (-1 = unset) - int8_t sdPin = -1; // serial data in (-1 = unset) - int8_t sckPin = -1; // bit clock (-1 = unset) - // 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. + int8_t wsPin = -1; ///< word-select / LRCLK (-1 = unset). Changing it re-creates the I2S channel live (no reboot). + int8_t sdPin = -1; ///< serial data in (-1 = unset). Changing it re-creates the I2S channel live. + int8_t sckPin = -1; ///< bit clock (-1 = unset). Changing it re-creates the I2S channel live. + /// 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 + /// (~11 kHz Nyquist covers the range that matters for light). Changing it + /// re-creates the channel live. 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). - // Simulated audio: fill the frame with a synthesized signal so audio-reactive effects are demoable - // (and testable) without a mic or music. Two patterns: - // `music` — a plausible song: multi-sine bands + a swelling volume + a periodic beat + a - // sweeping peak. Nice for demos (bars dance, VU breathes, peaks move). - // `sweep` — a single band lit, marching bass→treble on a timer, with the peak frequency and a - // steady volume tracking it. Deterministic — the clean test pattern to check that each - // effect responds across the whole spectrum. - // A real mic always wins: when `mode` is a fill-in mode it only runs while there's no real signal. - uint8_t simulate = 0; // 0 = off, 1 = music (fill silence), 2 = sweep (fill silence), - // 3 = music (always), 4 = sweep (always) + 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). + /// Simulated audio: fill the frame with a synthesized signal so audio-reactive effects are demoable + /// (and testable) without a mic or music, such as a preview/demo device with no microphone. Two patterns: + /// `music` — a plausible song: multi-sine bands + a swelling volume + a periodic beat + a + /// sweeping peak. Nice for demos (bars dance, VU breathes, peaks move). + /// `sweep` — a single band lit, marching bass→treble on a timer, with the peak frequency and a + /// steady volume tracking it. Deterministic — the clean test pattern to check that each + /// effect responds across the whole spectrum. + /// A real mic always wins: when `mode` is a fill-in ("silence") mode it only runs while there's no real signal. + uint8_t simulate = 0; ///< 0 = off, 1 = music (fill silence), 2 = sweep (fill silence), + ///< 3 = music (always), 4 = sweep (always) static constexpr uint16_t kSampleRates[] = {8000, 16000, 22050, 44100}; static constexpr uint8_t kSampleRateCount = 4; @@ -114,7 +158,7 @@ class AudioModule : public MoonModule { MoonModule::onBuildControls(); } - // A pin or rate change rebuilds the I2S channel. + /// A pin or rate change rebuilds the I2S channel (live, no reboot). 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; @@ -130,19 +174,22 @@ class AudioModule : public MoonModule { if (active_ == this) active_ = nullptr; // vacate; a surviving module re-elects itself in loop() } - // 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. + /// 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 FIRST live module claims the seat in setup(), - // vacates it in teardown(), and any running module re-claims an empty seat in loop() — so a device - // with two mics reads the first consistently, and removing the active one lets a survivor take over. - // Add/remove in any order leaves a coherent answer (the robustness rule). + /// 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 FIRST live module claims the seat in `setup()`, vacates + /// it in `teardown()`, and any running module re-claims an empty seat in + /// `loop()` — so a device with two mics reads the first consistently, and + /// removing the active one lets a survivor take over. 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; @@ -228,10 +275,10 @@ class AudioModule : public MoonModule { } } - // Fill frame_ with a synthesized signal. sweep=false → a plausible "song" (each band its own - // oscillator, a swelling volume, a periodic beat, a drifting peak); sweep=true → one band lit - // marching bass→treble on a timer (the deterministic test pattern). All integer LUT math (sin8), - // once per tick, off the per-light path. Also runs the same levelSmoothed EMA the mic path does. + /// Fill frame_ with a synthesized signal. sweep=false → a plausible "song" (each band its own + /// oscillator, a swelling volume, a periodic beat, a drifting peak); sweep=true → one band lit + /// marching bass→treble on a timer (the deterministic test pattern). All integer LUT math (sin8), + /// once per tick, off the per-light path. Also runs the same levelSmoothed EMA the mic path does. void synthesizeFrame(bool sweep) { const uint32_t t = platform::millis(); if (sweep) { @@ -309,6 +356,10 @@ class AudioModule : public MoonModule { static constexpr const char* kInitFailMsg = "mic init failed — check pins / rate"; + /// (Re)create the I2S channel for the current pins + rate. On a codec board the + /// I2S peripheral drives MCLK first, then the codec is configured over I2C. Any + /// unset pin (-1) or missing I2S support leaves the module idle with a status + /// note rather than attempting an init. Called from setup() and onBuildState(). void reinit() { if constexpr (!platform::hasI2sMic) { setStatus("mic: no I2S on this platform", Severity::Warning); diff --git a/src/core/Control.h b/src/core/Control.h index 59687a9b..4dfb84e9 100644 --- a/src/core/Control.h +++ b/src/core/Control.h @@ -86,56 +86,58 @@ inline void sanitizeHostname(char* buf) { *w = '\0'; } +/// The type of a control — selects its storage, its UI widget, and its DMX mapping. +/// Each `controls_.addX(name, var, …)` binds one of these to a class variable by +/// reference. Uint8 (a slider, 0–255) is the preferred default; the non-obvious +/// members are noted per value below. There is no RGB colour-picker type — effects +/// use a palette index (a Uint8) instead; `float` and `Coord3D` exist but are used +/// minimally, prefer Uint8. enum class ControlType : uint8_t { - Uint8, - Uint16, - Int16, // signed 16-bit. For coordinate-style controls where negative - // values are legal. (The light domain's grid coordinate type is - // int16 for this reason.) - Pin, // a GPIO number (int8_t storage, -1 = unused/default). Distinct - // from Int16 so the UI renders a plain number input, not a slider: - // a GPIO has no meaningful range to drag, and pins span 0..~52 - // across chips. Serializes/parses as a plain integer. - Bool, - Text, - TextArea, // multi-line text — identical char-buffer storage and parse/persist - // path to Text; differs only in the UI type string so the front-end - // renders a resizable <textarea> instead of a single-line input - // (used for script source and other multi-line fields). - Password, // secret text — /api/state serializes it XOR-obfuscated + - // base64-encoded, not plaintext. Obfuscation only (the XOR key - // is shared with app.js), so it is trivially reversible. - ReadOnly, // display-only text (ptr → char buffer) - ReadOnlyInt,// display-only signed int (ptr → int8_t, aux → const char* unit - // suffix e.g. "dBm"). UI renders "<value> <suffix>" verbatim. - // 1-byte storage where a string would be ~10 bytes — used for - // RSSI, TX power, future numeric telemetry. - Select, // dropdown (ptr → uint8_t index, aux → options array pointer) - Progress, // bar with value/total (ptr → uint32_t value, aux = total) - IPv4, // dotted-quad IP address (ptr → uint8_t[4]). 4 bytes of storage - // vs ~16 for a "192.168.255.255\0" string. Serializes/parses as - // the dotted-quad string at the JSON boundary. Used for the - // static-IP / gateway / subnet / DNS fields in NetworkModule. - List, // a variable list of rows (ptr → a ListSource the owning module - // implements). Each row serializes a flat summary object plus an - // optional detail object (the UI shows rows, expands a row to its - // detail). Read-only today — discovery output, not user-edited. - // The data lives in the module (a contiguous array it walks in - // place), NOT copied into the control system: a List adds zero - // persistent storage beyond the one descriptor pointer, the same - // "control holds a void* into module-owned data" shape every addX() - // uses, one level up. (Data-over-objects: no per-row object graph, - // no allocation on rebuild — see docs/architecture.md hot-path.) - Button, // a momentary action, not a stored value. The UI renders a button; - // a click POSTs a value and the module's onUpdate() runs the action. - // No backing storage (ptr unused) and non-persistable — distinct - // from Bool, which is an on/off STATE that renders as a toggle and a - // toggle is the wrong affordance for "do this now" (e.g. rescan). - Palette // a colour-palette dropdown (ptr → uint8_t index). Like Select, but - // each option carries its gradient *colours* (16 hex stops) so the UI - // renders a gradient swatch per option, not just a name. The light - // domain supplies the names + swatches via the Palette type; the wire - // shape (options:[{name,colors}]) is serialized in writeControlMetadata. + Uint8, ///< 1 byte, min/max — a 0–255 slider. The preferred default; DMX-mappable. + Uint16, ///< 2 bytes — a number input (universe, port). DMX-mappable. + Int16, ///< signed 16-bit, min/max — for coordinate-style controls where negatives + ///< are legal (the light grid coordinate type is int16). A bounded slider + ///< (unbounded → a ±percentage slider). DMX-mappable. + Pin, ///< a GPIO number (int8_t storage, -1 = unused/default). Distinct from Int16 + ///< so the UI renders a plain number input, not a slider (a GPIO has no + ///< meaningful drag range; pins span 0..~52). min/max clamp writes server-side. + Bool, ///< 1 byte — a toggle. DMX 0/1. + Text, ///< char[N] — a text input. + TextArea, ///< multi-line text — same storage/persist path as Text, a resizable + ///< `<textarea>` in the UI (script source and other multi-line fields). + Password, ///< secret text — /api/state serializes it XOR-obfuscated + base64, not + ///< plaintext. Obfuscation only (XOR key shared with app.js), trivially + ///< reversible by design — a first line of defence, not encryption. + ReadOnly, ///< display-only text (ptr → char buffer). + ReadOnlyInt,///< display-only signed int (ptr → int8_t, aux → a unit suffix such as + ///< "dBm"). Renders `<value> <suffix>`; 1 byte where a string would be + ///< ~10 (RSSI, TX power). See coding-standards § Prefer integers. + Select, ///< dropdown (ptr → uint8_t index, aux → options array pointer). DMX mode. + Progress, ///< bar with value/total (ptr → uint32_t value, aux = total). + IPv4, ///< dotted-quad IP address (ptr → uint8_t[4]). 4 bytes of storage + ///< vs ~16 for a "192.168.255.255\0" string. Serializes/parses as + ///< the dotted-quad string at the JSON boundary. Used for the + ///< static-IP / gateway / subnet / DNS fields in NetworkModule. + List, ///< a variable list of rows (ptr → a ListSource the owning module + ///< implements). Each row serializes a flat summary object plus an + ///< optional detail object (the UI shows rows, expands a row to its + ///< detail). Read-only today — discovery output, not user-edited. + ///< The data lives in the module (a contiguous array it walks in + ///< place), NOT copied into the control system: a List adds zero + ///< persistent storage beyond the one descriptor pointer, the same + ///< "control holds a void* into module-owned data" shape every addX() + ///< uses, one level up. (Data-over-objects: no per-row object graph, + ///< no allocation on rebuild — see docs/architecture.md hot-path.) + Button, ///< a momentary action, not a stored value. The UI renders a button; + ///< a click POSTs a value and the module's onUpdate() runs the action. + ///< No backing storage (ptr unused) and non-persistable — distinct + ///< from Bool, which is an on/off STATE that renders as a toggle and a + ///< toggle is the wrong affordance for "do this now" (e.g. rescan). + Palette ///< a colour-palette dropdown (ptr → uint8_t index). Like Select, but + ///< each option carries its gradient *colours* (16 hex stops) so the UI + ///< renders a gradient swatch per option, not just a name. The light + ///< domain supplies the names + swatches via the Palette type; the wire + ///< shape (options:[{name,colors}]) is serialized in writeControlMetadata. }; // Forward-declared (defined below the enum) so the descriptor can hold a pointer. @@ -209,6 +211,33 @@ struct ControlDescriptor { bool (*validate)(const char* value) = nullptr; }; +/// The set of controls a MoonModule exposes to the UI — a module's `controls_`. +/// +/// Controls bind to a class variable **by reference** — the descriptor stores a +/// pointer, hot-path code reads the variable directly (zero overhead, no +/// getter/setter). The value lives in the class variable (1–4 bytes); the descriptor +/// is just the metadata UI rendering and persistence need. +/// +/// **Memory footprint:** the descriptor stays lean — a variable pointer, a flash +/// name pointer, an `aux` word (Progress total / Select options), the type enum, the +/// int32 min/max, two UI flag bytes, and an optional validate hook (~48 bytes on a +/// 64-bit host, less on ESP32's 32-bit pointers). Descriptors live in a fixed-capacity +/// per-module array — no per-control heap allocation. A module that overflows the +/// default capacity is probably too complex. +/// +/// **Persistence and dynamic rebuild:** control values persist via FilesystemModule, +/// which overlays loaded values through each control's pointer during +/// `onBuildControls()`. Calling `onBuildControls()` again at runtime (e.g. when a +/// Select changes) clears and rebuilds the set, so only controls relevant to the +/// current mode are shown — this is how conditional `hidden` flags re-evaluate. +/// +/// The per-type storage/UI/DMX reference is on `ControlType`; each `addX` method +/// below binds one type. +/// +/// **Prior art:** MoonLight's `addControl` binds via +/// `reinterpret_cast<uintptr_t>(&variable)` with UI types +/// "slider"/"select"/"toggle"/"text"/"display" +/// (https://github.com/ewowi/MoonLight/blob/main/src/MoonBase/Nodes.h#L80). class ControlList { public: ~ControlList() { delete[] controls_; } @@ -219,15 +248,17 @@ class ControlList { ControlList(ControlList&&) = delete; ControlList& operator=(ControlList&&) = delete; + /// Bind a `uint8_t` as a 0–255 slider (the preferred default control). `min`/`max` + /// bound the UI drag range and clamp writes server-side. void addUint8(const char* name, uint8_t& var, uint8_t min = 0, uint8_t max = 255) { grow(); controls_[count_++] = {&var, name, 0, ControlType::Uint8, min, max}; } - // 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. + /// Bind a `uint16_t` as a number input. min/max default to the full type range + /// (no UI constraint); pass explicit bounds (e.g. `addUint16("sampleRate", r, + /// 8000, 48000)`) for a bounded slider + server-side write clamp — same contract + /// as addUint8/addInt16. void addUint16(const char* name, uint16_t& var, uint16_t min = 0, uint16_t max = UINT16_MAX) { grow(); @@ -429,10 +460,10 @@ void writeControlMetadata(JsonSink& sink, const ControlDescriptor& c); // HttpServerModule maps to 400-with-message; FilesystemModule treats // non-Ok as "leave existing"; scenario_runner returns false to the caller. enum class ApplyResult : uint8_t { - Ok, - OutOfRange, // numeric value outside the descriptor's bounds (Strict only) - Malformed, // IPv4 string didn't parse, etc. - ReadOnly, // tried to write a display-only control + Ok, ///< value parsed and applied. + OutOfRange, ///< numeric value outside the descriptor's bounds (Strict only). + Malformed, ///< the value didn't parse (e.g. a bad IPv4 string). + ReadOnly, ///< tried to write a display-only control. }; // Out-of-range policy for numeric / Select writes. The HTTP API wants @@ -440,7 +471,10 @@ enum class ApplyResult : uint8_t { // than silently get clamped); persistence load wants tolerant clamping // (a stale on-disk value from a schema change should still come close, // not silently drop to the default-constructed zero). -enum class ApplyPolicy : uint8_t { Strict, Clamp }; +enum class ApplyPolicy : uint8_t { + Strict, ///< reject an out-of-range value (the HTTP API — surfaces as a 400). + Clamp, ///< clamp to the nearest valid value (persistence load — tolerates stale on-disk values). +}; // Parse the JSON value at `json[key]` and apply it to the control's storage. // `json` is the enclosing JSON object's text; the function calls into diff --git a/src/core/DevicesModule.h b/src/core/DevicesModule.h index 7db9ee94..c979097e 100644 --- a/src/core/DevicesModule.h +++ b/src/core/DevicesModule.h @@ -16,31 +16,68 @@ namespace mm { -// Discovers other devices on the LAN by UDP presence broadcast and presents them as a -// browsable list. Core + domain-neutral: it finds "a projectMM / a WLED device" and light -// modules (Art-Net sync, future SuperSync, device groups) consume the list rather than -// living here. Submodule of NetworkModule — discovery depends on the network being up. -// See docs/moonmodules/core/DevicesModule.md. -// -// Discovery is PASSIVE UDP: each device BROADCASTS a small presence packet on a well-known -// port (WLED + projectMM both use UDP 65506 with the 44-byte WLED-compatible header — see -// WledPacket), and this module LISTENS (a bound UdpSocket per port its DevicePlugins claim, -// drained non-blocking each tick). A plugin classifies each datagram into a `Device`. -// projectMM also broadcasts its OWN presence on a slow cadence so peers discover it (and a -// WLED app browsing 65506 can list it too). This replaces the former mDNS *query* path, -// which destabilised our own mDNS advertise (a PTR query for a service we also host -// exhausts the IDF mDNS pool — see docs/history/decisions.md). mDNS is now -// advertise-ONLY (so the WLED native app + Home Assistant discover us); discovery never -// queries. Fast (no subnet sweep), hot-path-safe (non-blocking recv), extensible (a new -// ecosystem is one new plugin file). Reliable device-to-device *commands* ride REST; -// latency-critical sync rides its own UDP — never this listener (lossy-OK presence only). +/// Discovers other devices on the LAN by UDP presence broadcast and presents them +/// as a browsable list, focusing on *all* devices on the network (including this +/// one, marked as self) rather than the host's own state +/// +/// Core + domain-neutral: it finds "a projectMM / a WLED device" and light modules +/// (Art-Net sync, device groups) consume the list rather than living here, so its +/// card looks the same on every projectMM instance, ESP32 or PC. Submodule of +/// NetworkModule — discovery depends on the network being up (the same placement +/// reasoning as the Improv provisioning module), wired in `main.cpp` and marked +/// wired-by-code so persistence preserves it. +/// +/// **Discovery is passive UDP.** Each device BROADCASTS a small presence packet on +/// a well-known port (WLED + projectMM both use UDP 65506 with the 44-byte +/// WLED-compatible header — see `WledPacket`), and this module LISTENS (a bound +/// `UdpSocket` per port its `DevicePlugin`s claim, drained non-blocking each tick). +/// No subnet sweep, no per-host probe, no mDNS query — a device appears when its +/// broadcast arrives and ages out when it stops. projectMM also broadcasts its OWN +/// presence on a slow cadence (~10 s) so peers discover it, and a WLED app browsing +/// 65506 lists it too (discovery-only: a receiving WLED shows us in its instances +/// list, it does not sync to it). This replaces the former mDNS *query* path, which +/// destabilised our own mDNS advertise (a PTR query for a service we also host +/// exhausts the IDF mDNS pool — see docs/history/decisions.md). mDNS is +/// advertise-ONLY (announcing `_http._tcp`+`mm=1` and `_wled._tcp`+`mac=` so the +/// WLED native app + Home Assistant, which only browse mDNS, discover us); discovery +/// never queries. +/// +/// **Plugins are the interop seam.** Foreign ecosystems hook in as plugins, not +/// hardcoded branches (the adapter pattern, cf. `ListSource`, `ModuleFactory`): a +/// `DevicePlugin` declares its UDP port and turns a datagram into a `Device` kind. +/// `MmPlugin` claims a marked WLED-valid packet as projectMM (offered first, so a +/// projectMM peer isn't double-claimed as WLED); `WledPlugin` claims an unmarked one +/// as WLED. A new system is one new plugin file — no core edit. Out-of-band devices +/// (a Philips Hue bridge found over HTTP by a light-domain driver) register through +/// `upsertHueBridge()` via the `active()` boot-instance seam, keeping the Hue +/// pairing entirely in the driver. +/// +/// **Age-out + persistence.** Each sighting stamps `lastSeenMs`; a live-confirmed +/// device is kept `kStaleMs` (24 h) as a durable "devices I've seen" history, while +/// a cached row (restored from persistence, not yet re-heard) gets only a short +/// `kCachedGraceMs` (60 s) probation so a long-gone persisted device can't survive +/// forever across reboots. The `devices` List control is persistable, so the +/// last-known list is restored on boot (shown as "N devices (cached)") before the +/// first announcement arrives; the self row is re-added live with the current IP. +/// Storage is a fixed `devices_[kMaxDevices]` array — bounded, no heap. +/// +/// **Transport boundary.** This module does *discovery* only (lossy-OK presence, +/// never device-to-device commands). Consumers reach a found device over the right +/// transport: must-arrive config rides REST; latency-critical lossy-OK traffic +/// (time sync, live pixels) rides its own UDP stream. +/// +/// **Prior art:** the industry-standard mDNS-SD / DNS-SD (Bonjour, Avahi) +/// announce-and-browse pattern, plus MoonLight's UDP presence broadcast carried +/// forward as the 44-byte WLED-compatible packet on UDP 65506. See +/// docs/moonmodules/core/DevicesModule.md for the WLED-interop screenshots + the +/// wire shape. class DevicesModule : public MoonModule, public ListSource { public: - // Wire this device's own name (deviceName) before setup so the self row matches the - // status page / router / mDNS. Borrowed pointer — caller owns stable storage (SystemModule). + /// Wire this device's own name (deviceName) before setup so the self row matches the + /// status page / router / mDNS. Borrowed pointer — caller owns stable storage (SystemModule). void setSelfName(const char* name) { selfName_ = name; } - // ListSource — rows are produced straight from devices_ (no copy, no alloc). + /// ListSource — rows are produced straight from devices_ (no copy, no alloc). uint8_t listRowCount() const override { return deviceCount_; } void writeListRow(JsonSink& sink, uint8_t row) const override { @@ -82,12 +119,12 @@ class DevicesModule : public MoonModule, public ListSource { sink.append("}"); } - // ListSource restore (persistence load): parse the saved `devices` array with the - // recursive mm::json reader and rebuild devices_, so the last-known list shows on - // boot before any announcement arrives. Tolerant of a malformed/over-large file - // (parse fails → false → empty list). Self is dropped (re-added live via upsertSelf - // with the current IP). Tolerates an OLD persisted file with extra keys (e.g. the - // former `via`) — the keyed reader ignores them (robust to any input). + /// ListSource restore (persistence load): parse the saved `devices` array with the + /// recursive mm::json reader and rebuild devices_, so the last-known list shows on + /// boot before any announcement arrives. Tolerant of a malformed/over-large file + /// (parse fails → false → empty list). Self is dropped (re-added live via upsertSelf + /// with the current IP). Tolerates an OLD persisted file with extra keys (such as the + /// former `via`) — the keyed reader ignores them (robust to any input). bool restoreList(const char* json, const char* key) override { deviceCount_ = 0; const bool ok = mm::json::forEachListElement(json, key, @@ -131,16 +168,16 @@ class DevicesModule : public MoonModule, public ListSource { controls_.addList("devices", *this); // this module is the ListSource } - // The boot DevicesModule (exactly one exists). A foreign-bridge driver in the light domain - // (HueDriver) registers a discovered bridge through this without a compile-time dependency - // on DevicesModule's address — the same static-seam shape as AudioModule::latestFrame(). + /// The boot DevicesModule (exactly one exists). A foreign-bridge driver in the light domain + /// (a Hue driver) registers a discovered bridge through this without a compile-time dependency + /// on DevicesModule's address — the same static-seam shape as `AudioModule::latestFrame()`. static DevicesModule* active() { return active_; } - // Register a Hue bridge a HueDriver has connected to. Unlike upsertDevice (driven by a UDP - // presence packet), a bridge is discovered out-of-band — the driver already holds its IP + - // app key — so this is the explicit entry point for that. Idempotent: updates the name + - // colour count of the existing row, or inserts one. `colour` is how many of the bridge's - // lights are colour-capable, the figure for sizing a layout. Persisted like any device row. + /// Register a Hue bridge a light-domain driver has connected to. Unlike upsertDevice (driven by a UDP + /// presence packet), a bridge is discovered out-of-band — the driver already holds its IP + + /// app key — so this is the explicit entry point for that. Idempotent: updates the name + + /// colour count of the existing row, or inserts one. `colour` is how many of the bridge's + /// lights are colour-capable, the figure for sizing a layout. Persisted like any device row. void upsertHueBridge(const uint8_t ip[4], const char* name, uint8_t colour) { Device* d = findByIp(ip); bool persistChanged = false; @@ -179,10 +216,10 @@ class DevicesModule : public MoonModule, public ListSource { setStatus(statusBuf_); } - // Every tick: ensure we're online, drain inbound presence packets through the plugins, - // broadcast our own presence on a slow cadence, and age out devices unheard for - // kStaleMs. The drain is non-blocking (recvFrom returns -1 when nothing pending), so it - // never stalls the tick — the hot-path-safe replacement for the old mDNS query. + /// Every tick: ensure we're online, drain inbound presence packets through the plugins, + /// broadcast our own presence on a slow cadence, and age out devices unheard for + /// kStaleMs. The drain is non-blocking (recvFrom returns -1 when nothing pending), so it + /// never stalls the tick — the hot-path-safe replacement for the old mDNS query. void loop1s() override { MoonModule::loop1s(); uint8_t local[4] = {}; @@ -218,10 +255,10 @@ class DevicesModule : public MoonModule, public ListSource { ModuleRole role() const override { return ModuleRole::Generic; } - // Test seam: feed a synthetic presence datagram through the real classify→upsert - // pipeline, exactly as the live recvFrom loop does. The desktop unit/scenario tests - // drive the full discovery path (plugin claim, type priority, name/IP merge) with - // hand-built packets — no network needed. Not used in production. + /// Test seam: feed a synthetic presence datagram through the real classify→upsert + /// pipeline, exactly as the live recvFrom loop does. The desktop unit/scenario tests + /// drive the full discovery path (plugin claim, type priority, name/IP merge) with + /// hand-built packets — no network needed. Not used in production. void injectPacketForTest(const uint8_t* data, size_t len, const uint8_t srcIp[4]) { mergePacket(data, len, srcIp); } @@ -232,36 +269,36 @@ class DevicesModule : public MoonModule, public ListSource { char name[24] = {}; DevType type = DevType::Generic; bool self = false; - bool cached = false; // restored from persistence, not yet re-heard live this - // session. Cleared on the first live sighting. - uint32_t lastSeenMs = 0; // platform::millis() at the most recent mDNS sighting. - // Age-out drops a non-self device unheard for kStaleMs. - uint8_t colourCount = 0; // Hue bridge only: how many of its lights are colour-capable - // (the figure for sizing a layout). 0 for non-bridge rows. + bool cached = false; ///< restored from persistence, not yet re-heard live this + ///< session. Cleared on the first live sighting. + uint32_t lastSeenMs = 0; ///< platform::millis() at the most recent presence sighting. + ///< Age-out drops a non-self device unheard for kStaleMs. + uint8_t colourCount = 0; ///< Hue bridge only: how many of its lights are colour-capable + ///< (the figure for sizing a layout). 0 for non-bridge rows. }; // The boot instance, for active() — the foreign-bridge static seam (mirrors AudioModule). static inline DevicesModule* active_ = nullptr; - static constexpr uint8_t kMaxDevices = 32; // a LAN's worth; bounded, no heap - // Broadcast our presence every this-many loop1s ticks (≈ seconds). Slow + light, like - // WLED's ~30 s beacon; a new device appears within this window. A departed device - // clears within kStaleMs (sized to a few intervals so a present-but-quiet device isn't - // dropped between its broadcasts). + static constexpr uint8_t kMaxDevices = 32; ///< a LAN's worth; bounded, no heap + /// Broadcast our presence every this-many loop1s ticks (≈ seconds). Slow + light, like + /// WLED's ~30 s beacon; a new device appears within this window. A departed device + /// clears within kStaleMs (sized to a few intervals so a present-but-quiet device isn't + /// dropped between its broadcasts). static constexpr uint32_t kBroadcastEverySec = 10; - // Keep a device listed for 24 h after its last sighting, then drop. The list is a - // durable "devices I've seen" history (persisted to flash, restored on boot), not just - // "live right now": a device that goes offline survives a reboot and lingers a full day, - // its freshness dot ageing green → yellow (>1 min) → red (>1 h) so the UI shows it - // fading before it finally purges. A still-present device re-broadcasts every ~10 s, so - // 24 h is never hit by a live peer. + /// Keep a device listed for 24 h after its last sighting, then drop. The list is a + /// durable "devices I've seen" history (persisted to flash, restored on boot), not just + /// "live right now": a device that goes offline survives a reboot and lingers a full day, + /// its freshness dot ageing green → yellow (>1 min) → red (>1 h) so the UI shows it + /// fading before it finally purges. A still-present device re-broadcasts every ~10 s, so + /// 24 h is never hit by a live peer. static constexpr uint32_t kStaleMs = 24u * 60u * 60u * 1000u; - // Probation for a CACHED (restored-from-persistence, never-re-heard) device: keep it - // only this long for a live packet to re-confirm it, else drop it as a ghost. Short, so - // a stale persisted entry doesn't survive across reboots — the persisted file has no - // real last-seen time, so a cached device's clock is "boot", not "actually last seen". + /// Probation for a CACHED (restored-from-persistence, never-re-heard) device: keep it + /// only this long for a live packet to re-confirm it, else drop it as a ghost. Short, so + /// a stale persisted entry doesn't survive across reboots — the persisted file has no + /// real last-seen time, so a cached device's clock is "boot", not "actually last seen". static constexpr uint32_t kCachedGraceMs = 60u * 1000u; - static constexpr int kMaxDrainPerTick = 16; // cap packets processed per tick (bounded work) + static constexpr int kMaxDrainPerTick = 16; ///< cap packets processed per tick (bounded work) // The interop plugins. Order matters: MmPlugin is first, so a projectMM peer's // marker-stamped packet is typed projectMM before WledPlugin would see it as a plain diff --git a/src/core/FilesystemModule.h b/src/core/FilesystemModule.h index 80fcbf9c..edcafd3a 100644 --- a/src/core/FilesystemModule.h +++ b/src/core/FilesystemModule.h @@ -1,24 +1,5 @@ #pragma once -// FilesystemModule — control-list-driven JSON persistence. -// -// Storage: one flat JSON file per top-level MoonModule under /.config/<TypeName>.json. -// Children are encoded with "<index>." key prefix (positional) — a deliberately flat -// file shape, loaded with the cheap first-match key helpers (parseString/Int/Bool). A -// control whose VALUE is structured — a List control's array of objects — is read back -// with the recursive reader in core/JsonUtil.h via the control's own restore hook: the -// file's top level stays flat, the structure lives inside one control's value string. -// -// Boot flow: -// Scheduler phase 1: onBuildControls (every module binds full control set incl. hidden ones) -// Scheduler phase 2: this module's loadAllHook reads each file and overlays bound variables -// Scheduler phase 3: modules' own setup() runs with persisted values in member vars -// Scheduler phase 4: onBuildState -// -// Save flow: -// HttpServerModule::handleSetControl calls target->markDirty() on every mutation -// This module's loop1s() debounces 2s, walks the tree, writes any dirty subtree atomically -// // This is the .h interface. Bodies live in FilesystemModule.cpp — splitting them // out keeps the every-time-it-edits-recompile cost off the rest of the tree. @@ -32,6 +13,67 @@ namespace mm { class Scheduler; struct ControlDescriptor; +/// Control-list-driven JSON persistence — writes control values to flash so settings +/// survive a reboot. Always loaded, runs first in the scheduler so its load hook fires +/// before any other module's `setup()` +/// +/// **Storage:** one flat JSON file per top-level MoonModule under +/// `/.config/<TypeName>.json` (the filename is `MoonModule::typeName()`). Children are +/// encoded positionally with an `<index>.` key prefix — no nested objects, no arrays, +/// loaded with the cheap first-match key helpers (parseString/Int/Bool). A control +/// whose *value* is structured (a List control's array of objects) is read back with +/// the recursive reader in `core/JsonUtil.h` via the control's own restore hook: the +/// file's top level stays flat, the structure lives inside one control's value string. +/// `ReadOnly` and `Progress` controls are never persisted — they are derived values, +/// not state. +/// +/// **Structural reconciliation.** The `type` field per child drives reconciliation at +/// load time: when the JSON describes a child type at position N that differs from the +/// live tree's child at N, the loader factory-creates the JSON type, runs its +/// `onBuildControls()`, and swaps it into place. Children present in the live tree but +/// missing from the JSON are torn down; children in the JSON beyond the live tree's end +/// are appended. Phases 3+4 cascade into the reconciled tree, so newly-created children +/// are fully initialised like any other. +/// +/// **Boot flow (Scheduler::setup, four phases):** +/// - phase 1 `onBuildControls()` — every module binds its full control set (incl. hidden) +/// - phase 2 `loadAllHook` — this module reads each file and overlays bound variables +/// - phase 2b `rebuildControls()` — re-runs onBuildControls so conditional hidden flags see the persisted values +/// - phase 3 `setup()` — modules' own init runs with persisted values in members +/// - phase 4 `onBuildState()` — buffers sized to final values +/// +/// The Scheduler exposes `setLoadAllHook()` as a function pointer so it stays +/// independent of this module's type (no circular include); the hook is wired from +/// `setScheduler()`. +/// +/// **Save flow.** HttpServerModule calls `markDirty()` + `noteDirty()` on every +/// successful mutation — control changes AND tree-shape changes (add / delete / move a +/// module marks the parent dirty so its file is rewritten with the new child set). +/// `noteDirty()` stamps `lastDirtyMs_`; `loop1s()` waits `DEBOUNCE_MS` (2 s) after the +/// last dirty mark, then walks the tree and serialises any subtree with a dirty +/// descendant to a flat JSON blob, written atomically (write to `.tmp`, then rename). A +/// subtree's dirty flag clears only after its write succeeds; a failed write leaves it +/// set so `loop1s()` retries. `flushPending()` forces all dirty subtrees through +/// synchronously (the reboot handler calls it so an add-then-reboot doesn't lose the +/// change); losing power before the debounce expires loses the in-flight change — the +/// cost of debouncing for fewer flash writes. +/// +/// **Conditional visibility.** Modules with conditional controls bind their full +/// control set unconditionally and toggle a `hidden` flag per descriptor, so the +/// persistence layer can find and overlay a value regardless of the live conditional +/// state while the UI honours the flag. A Select change at runtime triggers +/// `rebuildControls()` to re-evaluate the flags. +/// +/// **Platform layer.** Filesystem access goes through `platform::fs*` (mount, mkdir, +/// read, atomic write-then-rename, used/total). ESP32 uses LittleFS on a dedicated +/// partition; desktop uses `std::filesystem` rooted at `build/` (overridable via +/// `fsSetRoot` for test isolation). Save/load shares one `MAX_FILE_BYTES` buffer; a +/// subtree that serialises larger than that fails the write. +/// +/// **First boot:** no files exist → load is a no-op, modules run with default +/// member-initialised values; after the first UI change the debounce creates the file. +/// A missing key keeps the default, an unknown key is silently ignored (no schema +/// migration). class FilesystemModule : public MoonModule { public: static constexpr const char* CONFIG_DIR = "/.config"; @@ -40,38 +82,39 @@ class FilesystemModule : public MoonModule { static constexpr size_t MAX_KEY = 48; static constexpr uint32_t DEBOUNCE_MS = 2000; - // Singleton is registered in setScheduler() (called by main.cpp on the real - // FilesystemModule), NOT in the constructor. The factory creates short-lived - // probe instances for /api/types defaults capture; the probe's destructor would - // otherwise clear instance_ and break noteDirty()/flushPending() for the rest - // of the device's life. + /// Singleton is registered in setScheduler() (called by main.cpp on the real + /// FilesystemModule), NOT in the constructor. The factory creates short-lived + /// probe instances for /api/types defaults capture; the probe's destructor would + /// otherwise clear instance_ and break noteDirty()/flushPending() for the rest + /// of the device's life. FilesystemModule() = default; ~FilesystemModule() override; - // Persistence must keep flushing dirty subtrees regardless of the `enabled` toggle — - // otherwise the user could lose changes by accidentally disabling this module via - // the UI before the 2s debounce expires. + /// Persistence must keep flushing dirty subtrees regardless of the `enabled` toggle — + /// otherwise the user could lose changes by accidentally disabling this module via + /// the UI before the 2s debounce expires. bool respectsEnabled() const override { return false; } void setScheduler(Scheduler* s); void setup() override; - // Read-only "last saved" display — surfaces in the UI so the user can see - // how long ago the config was last written (or "never" before any save). + /// Binds the read-only "lastSaved" display (how long ago the config was last written, + /// or "never" before any save) and the "filesystem" partition-usage progress bar (bound + /// only when the platform reports a real data partition; 0 → omitted). void onBuildControls() override; void loop1s() override; - // Synchronous save of every dirty subtree, bypassing the debounce. Same work - // loop1s does once the debounce expires. Exposed for tests so they can assert - // the file appears without wall-clock waits; production callers shouldn't need this. + /// Synchronous save of every dirty subtree, bypassing the debounce. Same work + /// loop1s does once the debounce expires. Exposed for tests so they can assert + /// the file appears without wall-clock waits; production callers shouldn't need this. void flush(); - // Static convenience for callers (e.g. reboot handler) that need to force any - // debounced saves through before a teardown — mirrors noteDirty's call style. + /// Static convenience for callers (such as the reboot handler) that need to force any + /// debounced saves through before a teardown — mirrors noteDirty's call style. static void flushPending(); - // Called by HttpServerModule on every successful control mutation so the - // 2s debounce starts. Cheap timestamp record; the actual walk happens in loop1s(). + /// Called by HttpServerModule on every successful control mutation so the + /// 2s debounce starts. Cheap timestamp record; the actual walk happens in loop1s(). static void noteDirty(); private: @@ -79,16 +122,16 @@ class FilesystemModule : public MoonModule { Scheduler* scheduler_ = nullptr; bool mounted_ = false; bool dirtyPending_ = false; - bool everSaved_ = false; // false until the first successful save + bool everSaved_ = false; ///< false until the first successful save uint32_t lastDirtyMs_ = 0; uint32_t lastSaveMs_ = 0; - char lastSaveStr_[24] = "never"; // "lastSaved" read-only control value - uint32_t fsUsedVal_ = 0; // "filesystem" progress: bytes used, refreshed in loop1s - uint32_t totalFsVal_ = 0; // "filesystem" progress: partition total, read once in onBuildControls - // Shared load/save buffer — load runs once at boot (phase 2), save runs in loop1s after - // the 2s debounce. Mutually exclusive, so one buffer is enough. Kept off the task stack - // since 2KB plus recursive applyNode/writeNode frames is uncomfortably close to the ESP32 - // default task stack ceiling (4–8KB). + char lastSaveStr_[24] = "never"; ///< "lastSaved" read-only control value + uint32_t fsUsedVal_ = 0; ///< "filesystem" progress: bytes used, refreshed in loop1s + uint32_t totalFsVal_ = 0; ///< "filesystem" progress: partition total, read once in onBuildControls + /// Shared load/save buffer — load runs once at boot (phase 2), save runs in loop1s after + /// the 2s debounce. Mutually exclusive, so one buffer is enough. Kept off the task stack + /// since 2KB plus recursive applyNode/writeNode frames is uncomfortably close to the ESP32 + /// default task stack ceiling (4–8KB). char fileBuf_[MAX_FILE_BYTES] = {}; // ---- Internals ---- diff --git a/src/core/FirmwareUpdateModule.h b/src/core/FirmwareUpdateModule.h index 4728c0b5..ff29ad5c 100644 --- a/src/core/FirmwareUpdateModule.h +++ b/src/core/FirmwareUpdateModule.h @@ -10,23 +10,6 @@ namespace mm { -// FirmwareUpdateModule — surfaces OTA flash progress as live read-only controls. -// -// Not user-configurable: ensureInfraModules() recreates it on every boot if -// absent (same safety net as NetworkModule). The actual flash is driven by -// POST /api/firmware/url in HttpServerModule — that handler spawns the OTA -// task via platform::http_fetch_to_ota(), which writes to two file-scope -// globals (g_otaStatus, g_otaBytesRead, g_otaBytesTotal). This module polls -// them in loop1s() and -// copies into its bound control buffers so the WebSocket state push picks up -// the change at 1 Hz. The shared-buffer + 1 Hz poll pattern is the simplest -// way to bridge a FreeRTOS task and a MoonModule on the scheduler thread -// without locks. No synchronisation: torn reads of -// the status string are acceptable for display-only fields. -// -// On desktop (platform::hasOta == false) the controls still exist for UI -// uniformity but the route returns 501; status stays "idle" forever. - // File-scope globals shared with the OTA route + the platform-layer task. // Declared `inline` (C++17) so multiple translation units that include the // header still share one storage instance (the header is included from @@ -46,11 +29,49 @@ inline char g_otaStatus[64] = "idle"; inline uint32_t g_otaBytesRead = 0; inline uint32_t g_otaBytesTotal = 0; +/// A thin status surface for OTA flashing — surfaces flash progress as live +/// read-only controls plus the per-module status banner +/// +/// Not user-configurable: `ensureInfraModules()` recreates it on every boot if +/// absent (same safety net as NetworkModule). The actual flash is driven by +/// `POST /api/firmware/url` in HttpServerModule, which hands the URL to +/// `platform::http_fetch_to_ota()` — a task that downloads via `esp_https_ota` and +/// writes the next OTA partition, communicating through the file-scope globals above +/// (`g_otaStatus`, `g_otaBytesRead`, `g_otaBytesTotal`). This module polls them in +/// `loop1s()` and copies into its bound control buffers so the WebSocket state push +/// picks up the change at 1 Hz. The shared-buffer + 1 Hz poll pattern is the simplest +/// way to bridge a FreeRTOS task and a MoonModule on the scheduler thread without +/// locks; no synchronisation, since torn reads of display-only fields are acceptable. +/// +/// **Controls.** `version` — pure semver (`MM_VERSION`): a stable release is a clean +/// `X.Y.Z`, a moving `latest` build is a monotonic prerelease `` `<core>-dev.<N>` `` (semver.org +/// §9/§11), so the release channel is derivable from the version rather than mixed into +/// it, keeping the string a clean machine-comparable semver the UI's update check compares +/// against the newest GitHub release. `build` — build date/time. `firmware` — the +/// build-time variant key (`esp32`, `esp32-eth`, `esp32s3-n16r8`, … / `desktop-*`) that +/// identifies which release asset matches the device (a legacy `esp32-eth-wifi` key +/// OTA-maps to `esp32`); the physical hardware is SystemModule's `deviceModel`. +/// `firmwarePartition` — running app image size / app-partition size (named distinctly +/// from `firmware` so a `find(name === "firmware")` caller resolves the string). +/// `update_pct` — live byte counters rendered "X KB / Y KB" (`total` is 0 until +/// `esp_https_ota_get_image_size` reports it just after the TLS handshake; the name is +/// historical, the wire shape is bytes). +/// +/// **Flash phase is not a control** — it surfaces through the module's shared status slot +/// (`setStatus()`): `idle` clears the banner, an `error:` prefix maps to `Severity::Error`, +/// everything else (`starting`/`downloading`/`flashing`/`rebooting`) is neutral. On +/// desktop (`platform::hasOta == false`) the controls still exist for UI uniformity but the +/// route returns 501 and status stays "idle" forever. +/// +/// **Prior art:** `esp_https_ota` is the standard ESP-IDF OTA-from-HTTP component used by +/// every ESP32 OTA flow since IDF v4.x; the install-picker UI is the new layer on top. See +/// docs/moonmodules/core/FirmwareUpdateModule.md for the `POST /api/firmware/url` wire +/// contract, the compatibility rules, and the flash lifecycle + error taxonomy. class FirmwareUpdateModule : public MoonModule { public: - // Diagnostics keep ticking regardless of the user toggle; matches - // SystemModule + NetworkModule. The user can't easily re-enable a - // disabled diagnostic module without it being visible. + /// Diagnostics keep ticking regardless of the user toggle; matches + /// SystemModule + NetworkModule. The user can't easily re-enable a + /// disabled diagnostic module without it being visible. bool respectsEnabled() const override { return false; } void setup() override { @@ -119,12 +140,12 @@ class FirmwareUpdateModule : public MoonModule { } } - // Point the shared status slot at our owned buffer, choosing the severity - // from the status text: the platform OTA task prefixes every failure with - // "error: " (see platform_esp32_ota.cpp), so that prefix is the Error gate. - // "idle" is the quiescent state and reads better as no banner than as an - // info banner, so it clears the slot. setStatus doesn't copy — statusStr_ - // outlives every call, so the pointer stays valid. + /// Point the shared status slot at our owned buffer, choosing the severity + /// from the status text: the platform OTA task prefixes every failure with + /// "error: " (see platform_esp32_ota.cpp), so that prefix is the Error gate. + /// "idle" is the quiescent state and reads better as no banner than as an + /// info banner, so it clears the slot. setStatus doesn't copy — statusStr_ + /// outlives every call, so the pointer stays valid. void publishStatus() { if (std::strcmp(statusStr_, "idle") == 0) { clearStatus(); @@ -140,11 +161,11 @@ class FirmwareUpdateModule : public MoonModule { uint32_t bytesRead_ = 0; uint32_t totalSnap_ = 0; // Firmware identity (static for this build) + the running app-partition usage. - char versionStr_[32] = {}; // pure semver — e.g. "2.0.0" or "2.1.0-dev.7" + char versionStr_[32] = {}; ///< pure semver — such as "2.0.0" or "2.1.0-dev.7" char buildStr_[24] = {}; - char firmwareStr_[24] = {}; // build variant name, e.g. "esp32s3-n16r8" - uint32_t firmwareSizeVal_ = 0; // bytes used in the app partition - uint32_t totalFlashVal_ = 0; // app partition size + char firmwareStr_[24] = {}; ///< build variant name, such as "esp32s3-n16r8" + uint32_t firmwareSizeVal_ = 0; ///< bytes used in the app partition + uint32_t totalFlashVal_ = 0; ///< app partition size }; } // namespace mm diff --git a/src/core/HttpServerModule.h b/src/core/HttpServerModule.h index 498b125e..a11c7b97 100644 --- a/src/core/HttpServerModule.h +++ b/src/core/HttpServerModule.h @@ -12,21 +12,74 @@ namespace mm { class JsonSink; class Scheduler; -// HttpServerModule serves the UI's REST API, the static UI assets, and the -// WebSocket channel that pushes state JSON + binary preview frames. Implementation -// lives in HttpServerModule.cpp — this header is the interface only. -// -// The five `JsonSink&` helpers below are private members rather than free -// functions because they all read `this->wsClients_`, `this->scheduler_`, or -// other module state, or call other HttpServerModule members. -// -// Three pieces of this module's helpers used to live inline here and have -// been extracted into their own headers: -// - JsonSink class + jsonEscape() → core/JsonSink.h -// - sha1() (RFC 3174, WS handshake) → core/Sha1.h -// - base64Encode() (WS handshake + Password obfuscation) → core/Base64.h -// They live in `namespace mm` so the call sites in HttpServerModule.cpp are -// unchanged. +/// Embedded HTTP server plus WebSocket — serves the web UI and the REST API that backs it. +/// Core infrastructure with NO light-domain dependencies (no `PreviewFrame`, no light types, +/// no light includes). Implementation lives in HttpServerModule.cpp; this header is the +/// interface only. The `port` control defaults to 8080 on desktop, 80 on ESP32. +/// +/// **REST API:** `GET /` serves index.html and the UI assets (`/app.js`, `/style.css`, +/// `/moonlight-logo.png`). `GET /api/state` returns the full module-tree JSON (each entry +/// carries name, type, role, enabled, loopTimeUs, classSize, dynamicBytes, `controls[]`, +/// status + severity when set, and `userEditable:false` only when the module opts out of +/// UI delete/replace). `GET /api/system` returns fps, tickTimeUs, freeHeap, freeInternal, +/// maxBlock, uptime. `GET /api/types` returns the type catalog (stable factory `name`, +/// role-suffix-stripped `displayName`, `acceptsChildRoles`, and per-type `defaults` captured +/// from a fresh probe instance). Mutations: `POST /api/control` `{module,control,value}`, +/// `POST /api/modules` create, `POST /api/modules/{name}/move` reorder, `.../replace` swap, +/// `POST /api/reboot`, `DELETE /api/modules/{name}`. All JSON responses stream through a +/// `JsonSink` — no fixed-buffer ceiling, so a tree of any size serialises correctly. +/// +/// **WebSocket:** `GET /ws` with `Upgrade: websocket` does the RFC 6455 handshake (SHA-1 + +/// base64), up to 4 concurrent clients. Server pushes full state JSON as text frames from +/// `loop1s()`. Binary frames take two paths, both without a frame-sized buffer: a synchronous +/// stream (`beginBinaryFrame` / `pushBinaryFrame` / `endBinaryFrame`) for a forward-only +/// producer, and a resumable buffered send (`sendBufferedFrame`) that drains a memory-adaptive +/// chunk per client per `loop20ms` from a stable caller-owned buffer — so a large frame is +/// delivered over wall-clock ticks without spinning any loop, yet stays one atomic WS message. +/// One buffered send is in flight at a time (newest-wins backpressure: a new offer while one +/// is active is dropped). Clients send nothing back over WS; mutations go through REST. +/// +/// **Hot-path split:** the resumable drain runs on `loop20ms` (the 20 ms transport poll), +/// deliberately NOT the per-render-tick `loop()`, so pushing preview bytes to the socket is +/// never charged to the LED render hot path. The LED output is never delayed by the preview; +/// the preview frame rate is instead bounded by the 20 ms drain cadence, which is the right +/// trade since the preview is a view and the LEDs are not. +/// +/// **WLED-compatibility shim:** a small set of WLED-shaped messages make a projectMM device +/// appear in — and be controlled from — the native WLED apps (iOS / Android) and Home +/// Assistant's WLED integration. Discovery is over mDNS `_wled._tcp`; validation is a minimal +/// `GET /json/info` `{name, mac, leds{}, wifi{}, brand:"WLED", product:"MoonModules"}` (the +/// app keys on `brand:"WLED"` to accept it — we interoperate, not impersonate; this is NOT a +/// full WLED emulation). Live state is pushed over `/ws` as a `{state, info}` frame; `state` +/// mirrors the Drivers `brightness` control and the live first-LED RGB (falling back to +/// projectMM purple `[128,0,255]` when the first LED is off). Control is bidirectional over the +/// same `/ws`: the app's slider/toggle send a `{on?, bri?}` frame, read by +/// `pollWledStateFromWebSockets()` and applied to Drivers brightness through the shared +/// apply-core (the same `applySetControl` path REST and Improv use). The colour read is the +/// one place this core module reaches output state — `MoonModule::firstOutputRgb()` is a +/// domain-neutral virtual the light-domain Drivers overrides — keeping this module free of any +/// light-domain include. +/// +/// **Cross-domain wiring:** this module exposes the `BinaryBroadcaster` interface; the +/// light-domain PreviewDriver holds a `BinaryBroadcaster*` and streams each frame's bytes +/// through it. `main.cpp` wires PreviewDriver's broadcaster to the HttpServerModule instance — +/// the only file that knows both. The preview's point budget and wire format are PreviewDriver's +/// concern. +/// +/// The five `JsonSink&` helpers below are private members rather than free functions because +/// they all read `this->wsClients_`, `this->scheduler_`, or other module state, or call other +/// HttpServerModule members. Three pieces of this module's helpers live in their own headers: +/// JsonSink + jsonEscape() in core/JsonSink.h, sha1() (RFC 3174, WS handshake) in core/Sha1.h, +/// base64Encode() (WS handshake + Password obfuscation) in core/Base64.h — all in `namespace mm` +/// so the call sites are unchanged. +/// +/// **Prior art:** the WLED-compatibility shim's exact field requirements were reverse-engineered +/// from the WLED-Android client by Christophe Gagnier (@Moustachauve, +/// https://github.com/Moustachauve/WLED-Android) — `DeviceDiscovery.kt` (mDNS browse), +/// `DeviceFirstContactService.kt` (the `/json/info` validation + non-empty `mac` check), the +/// Info/State Moshi models, and `WebsocketClient.kt` (live state over `/ws`, the `sendState` +/// control direction). Knowing precisely what the app reads is why the shim is the minimal +/// accepted object rather than a guessed full WLED emulation. class HttpServerModule : public MoonModule, public BinaryBroadcaster { public: uint16_t port = 8080; @@ -34,28 +87,28 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { void setScheduler(Scheduler* s) { scheduler_ = s; } void setUiPath(const char* path) { uiPath_ = path; } - // BinaryBroadcaster — stream one binary WS frame to every connected client, pushed - // incrementally so no frame-sized buffer is held. Producers (PreviewDriver) push the - // payload bytes; this prepends the WS header. Domain-neutral: no knowledge of the content. + /// BinaryBroadcaster — stream one binary WS frame to every connected client, pushed + /// incrementally so no frame-sized buffer is held. Producers (PreviewDriver) push the + /// payload bytes; this prepends the WS header. Domain-neutral: no knowledge of the content. void beginBinaryFrame(size_t totalLen) override; void pushBinaryFrame(const uint8_t* data, size_t len) override; bool endBinaryFrame() override; - // Resumable one-frame send from a stable caller-owned buffer (no copy), drained a bounded chunk - // per client per loop20ms (drainPreviewSend) so a large frame stays off this module's hot path; - // a would-block socket resumes next tick. See BinaryBroadcaster. + /// Resumable one-frame send from a stable caller-owned buffer (no copy), drained a bounded chunk + /// per client per loop20ms (drainPreviewSend) so a large frame stays off this module's hot path; + /// a would-block socket resumes next tick. See BinaryBroadcaster. bool sendBufferedFrame(const uint8_t* header, size_t headerLen, const uint8_t* body, size_t bodyLen) override; bool bufferedSendIdle() const override { return !previewSend_.active; } void cancelBufferedSend() override { previewSend_.active = false; } - // Bumped on each new WS client (see handleWebSocketUpgrade). PreviewDriver watches it to - // re-stream its coordinate table the moment a fresh page connects, so a refresh shows the - // preview immediately. + /// Bumped on each new WS client (see handleWebSocketUpgrade). PreviewDriver watches it to + /// re-stream its coordinate table the moment a fresh page connects, so a refresh shows the + /// preview immediately. uint32_t clientGeneration() const override { return wsClientGeneration_; } - // Keep running even when "disabled" via the UI — otherwise the user has no way - // to re-enable themselves through the same UI. The `enabled` checkbox on this - // card has no effect; that's intentional. + /// Keep running even when "disabled" via the UI — otherwise the user has no way + /// to re-enable themselves through the same UI. The `enabled` checkbox on this + /// card has no effect; that's intentional. bool respectsEnabled() const override { return false; } void onBuildControls() override; @@ -67,31 +120,31 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { // ----------------------------------------------------------------------- // Transport-free apply-core — "the REST API, callable in-process" // ----------------------------------------------------------------------- - // The add/set/clear-children operations the HTTP handlers do, factored out of - // the TcpConnection so any transport can drive them. Two callers today: the - // HTTP handlers (thin wrappers that map OpResult → status code) and the Improv - // serial path (ImprovProvisioningModule applies a pushed op on the main loop — - // "Improv = REST over serial"). One home for the apply logic; transports differ - // only in how they frame the request and report the result. + /// The add/set/clear-children operations the HTTP handlers do, factored out of + /// the TcpConnection so any transport can drive them. Two callers today: the + /// HTTP handlers (thin wrappers that map OpResult → status code) and the Improv + /// serial path (ImprovProvisioningModule applies a pushed op on the main loop — + /// "Improv = REST over serial"). One home for the apply logic; transports differ + /// only in how they frame the request and report the result. enum class OpResult : uint8_t { Ok, - AlreadyExists, // add is a no-op: a module with this id is already in the tree (still success) - ModuleNotFound, // module / parent name not in the tree - ControlNotFound, // module exists but has no such control (a distinct 404) - UnknownType, // factory doesn't know the type - BadRequest, // missing field, top-level add, parent rejected child - OutOfRange, // numeric value outside bounds - Malformed, // value didn't parse (e.g. IPv4) - ReadOnly, // tried to write a display-only control + AlreadyExists, ///< add is a no-op: a module with this id is already in the tree (still success) + ModuleNotFound, ///< module / parent name not in the tree + ControlNotFound, ///< module exists but has no such control (a distinct 404) + UnknownType, ///< factory doesn't know the type + BadRequest, ///< missing field, top-level add, parent rejected child + OutOfRange, ///< numeric value outside bounds + Malformed, ///< value didn't parse (such as an IPv4) + ReadOnly, ///< tried to write a display-only control }; - // body is a small JSON object: {"type","id","parent_id"} / {"module","control","value"}. + /// body is a small JSON object: `{"type","id","parent_id"}` / `{"module","control","value"}`. OpResult applyAddModule(const char* typeName, const char* id, const char* parentId); OpResult applySetControl(const char* moduleName, const char* controlName, const char* valueJson); - // Enumerate-then-DELETE every child of `parentName` (the catalog inject's - // replaceChildren). Returns NotFound if the parent doesn't exist, else Ok. + /// Enumerate-then-DELETE every child of `parentName` (the catalog inject's + /// replaceChildren). Returns NotFound if the parent doesn't exist, else Ok. OpResult applyClearChildren(const char* parentName); - // Parse a single REST op object ({"op":"add|set|clearChildren", …}) and dispatch - // to the three above. The wire shape the Improv APPLY_OP frame carries. + /// Parse a single REST op object (`{"op":"add|set|clearChildren", …}`) and dispatch + /// to the three above. The wire shape the Improv APPLY_OP frame carries. OpResult applyOp(const char* opJson); private: @@ -179,15 +232,15 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { // System metrics // ----------------------------------------------------------------------- void serveSystem(platform::TcpConnection& conn); - // WLED-compatibility shim — see the /json/info route + the impl rationale. /json/info - // lists the device; /json/state + /json/si carry on/brightness/colour for the card; - // POST /json/state maps the app's toggle + slider onto Drivers brightness. + /// WLED-compatibility shim — see the class comment + the /json/info route. /json/info + /// lists the device; /json/state + /json/si carry on/brightness/colour for the card; + /// POST /json/state maps the app's toggle + slider onto Drivers brightness. void serveWledInfo(platform::TcpConnection& conn); void serveWledState(platform::TcpConnection& conn); void serveWledStateInfo(platform::TcpConnection& conn); void handleWledState(platform::TcpConnection& conn, const char* body); - void applyWledState(const char* body); // {on,bri} → Drivers brightness (HTTP + WS) - void pollWledStateFromWebSockets(); // read app's slider/toggle sent over /ws + void applyWledState(const char* body); ///< `{on,bri}` → Drivers brightness (HTTP + WS) + void pollWledStateFromWebSockets(); ///< read app's slider/toggle sent over /ws void writeWledInfoBody(JsonSink& sink, const char* name, const uint8_t mac[6]); void writeWledStateBody(JsonSink& sink); uint8_t driversBrightness(); @@ -203,9 +256,9 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { void writeTypeDefaults(JsonSink& sink, const char* typeName); void handleMoveModule(platform::TcpConnection& conn, const char* moduleName, const char* body); void handleReboot(platform::TcpConnection& conn); - // OTA: POST /api/firmware/url body={"url":"..."}. Body parsed; URL handed - // to platform::http_fetch_to_ota which spawns a task and returns. Caller - // gets 202 immediately; progress streams via FirmwareUpdateModule controls. + /// OTA: `POST /api/firmware/url` body=`{"url":"..."}`. Body parsed; URL handed + /// to platform::http_fetch_to_ota which spawns a task and returns. Caller + /// gets 202 immediately; progress streams via FirmwareUpdateModule controls. void handleFirmwareUrl(platform::TcpConnection& conn, const char* body); // ----------------------------------------------------------------------- diff --git a/src/core/I2cScanModule.h b/src/core/I2cScanModule.h index 5bf6f227..7fda8468 100644 --- a/src/core/I2cScanModule.h +++ b/src/core/I2cScanModule.h @@ -1,21 +1,5 @@ #pragma once -// I2cScanModule — a diagnostic that scans an I2C bus and reports which device -// addresses ACK (the standard `i2cdetect`). Domain-neutral: any I2C bring-up -// (an audio codec, a sensor, a port expander) uses it to confirm wiring and -// read off a device's address. Pressing `scan` probes the bus on the `sda` / -// `scl` pins and lists the 7-bit addresses found in `result`. -// -// Same shape as DevicesModule (a momentary `scan` button → results), one rung -// simpler: the bus is local (no persisted list, no live age-out), so a single -// read-only `result` string suffices instead of a ListSource. -// -// The pins are controls (defaulting unset) so each board sets its bus pins in -// docs/install/deviceModels.json — the same per-board pin-config pattern as the -// driver/audio modules. The actual probe is platform::i2cScan (platform.h), a -// self-contained seam that opens a temporary bus, scans, and tears it down, so -// the diagnostic never fights a bus another driver owns. - #include "core/MoonModule.h" #include "platform/platform.h" // i2cScan @@ -25,10 +9,43 @@ namespace mm { +/// A core, domain-neutral diagnostic that scans an I2C bus and reports which device +/// addresses ACK — the standard `i2cdetect` operation, surfaced in the UI. It is the bring-up +/// tool for any I2C peripheral (an audio codec, a sensor, a port expander): set the bus pins, +/// press scan, read off the addresses present, confirming wiring before a driver tries to talk +/// to the device. Pressing `scan` probes the bus on the `sda` / `scl` pins and lists the 7-bit +/// addresses found in `result`. +/// +/// Same shape as DevicesModule (a momentary `scan` button → results), one rung simpler: the bus +/// is local (no persisted list, no live age-out), so a single read-only `result` string suffices +/// instead of a ListSource. Scan state ("N devices found", "set sda + scl pins first") reports +/// through the standard `setStatus()` channel. +/// +/// **Not auto-wired.** Factory-registered like AudioModule, so a board with an I2C bus adds it +/// through the installer device catalog (its `sda`/`scl` controls carrying that board's bus +/// pins) or the user adds it from the UI. The pins default to GPIO21/22, the Arduino-ESP32 +/// core's conventional I2C pair, so the control pre-fills a sensible starting point on a classic +/// ESP32 (the pins route through the GPIO matrix, so they're a convention, not fixed hardware); +/// a board with a fixed bus overrides them in its catalog entry (such as the S31's `sda:51, +/// scl:50`). +/// +/// **How it works:** the probe is `platform::i2cScan(sda, scl, out, maxOut)` (declared in +/// platform.h), a self-contained seam that opens a temporary I2C master bus on the given pins, +/// probes every 7-bit address (`0x01`–`0x77`), writes the ACKing addresses into the caller's +/// buffer, and tears the bus down. Opening its own short-lived bus (rather than borrowing one) +/// means the scan never conflicts with a bus another driver owns — the ES8311 codec on the +/// ESP32-S31 holds its own bus, and the scan probes the same pins independently between codec +/// operations. On a target without an I2C bus (an I2C-less ESP32, or desktop) the seam returns +/// `kI2cBusUnavailable`, so the scan reports "bus unavailable" rather than a misleading "0 +/// devices found" — the 0 is reserved for a real scan where nothing ACKed. +/// +/// **Prior art:** the bus-scan-as-a-feature mirrors MoonLight's I2C scan diagnostic; the seam +/// name and probe range follow the Linux `i2c-tools` `i2cdetect` convention +/// (https://manpages.debian.org/i2c-tools/i2cdetect.8.en.html). class I2cScanModule : public MoonModule { public: - // A diagnostic, like FirmwareUpdateModule / DevicesModule — keeps its - // controls and the scan action available regardless of the `enabled` toggle. + /// A diagnostic, like FirmwareUpdateModule / DevicesModule — keeps its + /// controls and the scan action available regardless of the `enabled` toggle. bool respectsEnabled() const override { return false; } ModuleRole role() const override { return ModuleRole::Peripheral; } diff --git a/src/core/ImprovFrame.h b/src/core/ImprovFrame.h index af131cf0..6bcd59fc 100644 --- a/src/core/ImprovFrame.h +++ b/src/core/ImprovFrame.h @@ -26,22 +26,24 @@ namespace mm { // Framing constants — match the spec verbatim. The library also defines // these (improv.h: IMPROV_SERIAL_VERSION etc.) but we don't pull that in // here to keep this header dependency-free for host-side testing. +// --8<-- [start:frame-constants] inline constexpr uint8_t kImprovMagic[6] = {'I','M','P','R','O','V'}; inline constexpr uint8_t kImprovSerialVersion = 1; inline constexpr size_t kImprovMaxPayload = 128; // RPC bodies are well under this -// Frame types from the spec; named without the protocol prefix to avoid -// shadowing the library's `improv::ImprovSerialType` enum where both are -// in scope (the test code only includes this header; the ESP32 task -// includes both and dispatches at the boundary). +/// Frame types from the spec; named without the protocol prefix to avoid +/// shadowing the library's `improv::ImprovSerialType` enum where both are +/// in scope (the test code only includes this header; the ESP32 task +/// includes both and dispatches at the boundary). enum class ImprovFrameType : uint8_t { CurrentState = 0x01, ErrorState = 0x02, Rpc = 0x03, RpcResponse = 0x04, }; +// --8<-- [end:frame-constants] -// Result of feeding a byte to the parser. +/// Result of feeding a byte to the parser. enum class ImprovFeedResult : uint8_t { NeedMore, // mid-frame; keep feeding FrameReady, // a complete, checksum-valid frame is in lastType()/lastPayload() @@ -49,14 +51,14 @@ enum class ImprovFeedResult : uint8_t { OversizePayload, // length byte > kImprovMaxPayload; resync }; -// Byte-at-a-time framing parser. Resets to the magic-search state after -// every completed frame (or error). Caller owns the parser; one instance -// per UART channel. +/// Byte-at-a-time framing parser. Resets to the magic-search state after +/// every completed frame (or error). Caller owns the parser; one instance +/// per UART channel. class ImprovFrameParser { public: - // Returns NeedMore until a full frame has been read. On FrameReady the - // caller can read lastType() + lastPayload()/lastPayloadLen(). The - // buffers are valid until the next feed() call. + /// Feed one received byte. Returns NeedMore until a full frame has been read. + /// On FrameReady the caller can read lastType() + lastPayload()/lastPayloadLen(); + /// those buffers are valid until the next feed() call. ImprovFeedResult feed(uint8_t byte) { switch (state_) { case State::Magic0: case State::Magic1: case State::Magic2: diff --git a/src/core/ImprovProvisioningModule.h b/src/core/ImprovProvisioningModule.h index 9d35a15f..534efb0c 100644 --- a/src/core/ImprovProvisioningModule.h +++ b/src/core/ImprovProvisioningModule.h @@ -13,35 +13,81 @@ namespace mm { -// ImprovProvisioningModule — listens for Improv WiFi frames on UART0 and -// pushes credentials into NetworkModule. Browser drives the protocol via -// ESP Web Tools / improv-wifi.com; a Python CLI mirror lives at -// scripts/build/improv_provision.py for rack / CI use over USB. -// -// The actual protocol parsing + UART task lives in the platform layer -// (`mm::platform::improvProvisioningInit` at platform.h). This module is the -// status surface: one read-only `provision_status` Control that reports -// "listening" / "received credentials" / "connecting" / "connected: <ssid>" -// / "error: …". Module's loop1s() polls a `ready` flag the platform task -// sets when credentials arrive, then calls NetworkModule::setWifiCredentials -// which writes through to the same buffers the AP-fallback UI flow uses. -// -// On desktop (platform::hasImprov == false) the module exists for UI -// uniformity; the listener-install is skipped. - +/// Browser-driven WiFi provisioning over USB-serial, using the Improv-WiFi protocol +/// (https://www.improv-wifi.com/, from Nabu Casa). Improv is an open standard for handing a +/// device its WiFi credentials over a *local* link — USB serial here — at the moment it has no +/// network yet, solving the bootstrap chicken-and-egg: a freshly-flashed ESP32 isn't on your +/// WiFi, so you can't reach it over the network to give it the password; Improv carries that +/// first handoff over the cable the browser is already connected to from flashing. projectMM +/// extends this past credentials with the `APPLY_OP` vendor RPC ("Improv = REST over serial") +/// to push the whole device config (including the deviceModel identity, which is just one of +/// the config controls) over the same already-there link. +/// +/// This module is the status surface: one read-only `provision_status` control that reports +/// `listening` / `received credentials` / `connecting` / `connected: <ssid>` / `error: reason` +/// / `not supported on this platform` (desktop). The actual protocol parsing + UART task live +/// in the platform layer (`mm::platform::improvProvisioningInit`, platform.h). loop1s() polls a +/// `ready` flag the platform task sets when credentials arrive, then calls +/// NetworkModule::setWifiCredentials, which writes through to the same buffers the AP-fallback +/// UI flow uses. A code-wired child of NetworkModule (`markWiredByCode()`) so persistence-apply +/// preserves it across reboots even on devices whose `Network.json` predates the addition. On +/// desktop (`platform::hasImprov == false`) the module exists for UI uniformity; the +/// listener-install is skipped. +/// +/// **Transports.** The listener serves both UART0 (external USB-to-UART bridges) and the S3's +/// native USB-Serial-JTAG port, so a native-USB-only board provisions over that port directly. +/// 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). Both transports speak the same Improv-WiFi +/// serial protocol — frames of `IMPROV` + version byte + type + length + payload + checksum +/// (full spec: https://www.improv-wifi.com/serial/; the constants are authored in +/// ImprovFrame.h). +/// +/// **RPCs.** Four standard commands plus two vendor extensions: `GET_CURRENT_STATE` (authorized +/// / provisioned), `GET_DEVICE_INFO` (`[firmware, version, chipFamily, deviceName]`), +/// `GET_WIFI_NETWORKS` (synchronous scan, up to 10 SSIDs — rejected while STA is connected), +/// `WIFI_SETTINGS` (writes SSID + password, polls `wifiStaConnected()` up to 30 s, replies with +/// `http://ip/` or `ERROR_UNABLE_TO_CONNECT`). Vendor `SET_TX_POWER` (`0xFD`) persists + applies +/// a pre-association TX-power cap *before* any association attempt — the escape hatch for boards +/// whose LDO browns out at full TX power, since the cap must land before the first association or +/// the board fails auth at 20 dBm before it is ever online. Vendor `APPLY_OP` (`0xFC`) carries +/// ONE REST op as JSON, routed to HttpServerModule's apply-core — the exact same code the HTTP +/// handlers call — so a REST call over the network and an `APPLY_OP` over serial execute +/// identically. The web installer pushes a device-model's whole catalog config this way during +/// provisioning (the deviceModel identity is just one `set System.deviceModel` op), so the +/// defaults apply over the serial port the installer already owns during the flash — which is +/// what lets the HTTPS installer page configure an `http://` device a browser fetch can't reach +/// (mixed-content). Ops are idempotent; a long value chunks across frames into a reassembly +/// buffer, applied on the device's main loop when `last=1`; single-buffered (a new op errors +/// while the previous is unconsumed). +/// +/// **Eth-only builds.** The serial listener runs on every ESP32 target, including Ethernet-only +/// builds: they compile in the vendor RPCs plus `GET_CURRENT_STATE` / `GET_DEVICE_INFO`, so the +/// installer pushes config over serial to an eth device exactly as to a WiFi one. The +/// WiFi-provisioning RPCs (`WIFI_SETTINGS`, `GET_WIFI_NETWORKS`) build only on WiFi targets; on +/// eth, `GET_CURRENT_STATE` reports "provisioned" + the URL from the Ethernet link. +/// `WIFI_SETTINGS` and `GET_WIFI_NETWORKS` are both rejected while `platform::wifiStaConnected()` +/// — the scan gate protects large installs, since `esp_wifi_scan_start` puts the radio into scan +/// mode for 2-5 s, dropping inbound ArtNet packets (a visible glitch on a 16K-LED rig). +/// +/// **Prior art:** Improv-WiFi is the standard ESPHome / Home Assistant use for cross-firmware +/// WiFi provisioning (spec: https://www.improv-wifi.com/serial/; reference C++: +/// https://github.com/improv-wifi/sdk-cpp). projectMM-v1's `deploy/wifi.py` + +/// `deploy/flashfs.py --wifi` covered the same rack-provisioning use case by baking credentials +/// into a LittleFS partition image and re-flashing; Improv replaces that with live serial +/// provisioning (devices stay running, no flash mode required). class ImprovProvisioningModule : public MoonModule { public: void setSystemModule(SystemModule* s) { systemModule_ = s; } void setNetworkModule(NetworkModule* n) { networkModule_ = n; } - // For the APPLY_OP vendor RPC — the module routes a pushed REST op to the - // HttpServerModule's apply-core (the same code /api/modules + /api/control use). + /// For the APPLY_OP vendor RPC — the module routes a pushed REST op to the + /// HttpServerModule's apply-core (the same code /api/modules + /api/control use). void setHttpServerModule(HttpServerModule* h) { httpServerModule_ = h; } - // Diagnostics keep ticking; matches FirmwareUpdateModule / SystemModule. + /// Diagnostics keep ticking; matches FirmwareUpdateModule / SystemModule. bool respectsEnabled() const override { return false; } - // Apparatus, not swappable content — provisioning is a fixed device service. - // Not deletable (matches Board / Preview); can still be disabled. + /// Apparatus, not swappable content — provisioning is a fixed device service. + /// Not deletable (matches Board / Preview); can still be disabled. bool userEditable() const override { return false; } void setup() override { @@ -71,6 +117,11 @@ class ImprovProvisioningModule : public MoonModule { controls_.addReadOnly("provision_status", statusStr_, sizeof(statusStr_)); } + /// Poll the SET_TX_POWER cap then the WiFi credentials the Improv task published (an + /// acquire-load pairs each atomic flag with the platform task's release-store so the buffer + /// writes are visible before we read them). The TX-power cap is applied first on purpose (see + /// the inline note), and the deviceModel arrives like any other catalog default via the + /// APPLY_OP poll below rather than here. 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, @@ -98,12 +149,14 @@ class ImprovProvisioningModule : public MoonModule { // control's per-control validator (handled in the APPLY_OP poll below). } - // APPLY_OP is polled per-TICK (not loop1s) because the installer pushes a burst - // of ops during provisioning and single-buffers them: the Improv task refuses a - // new op until this consumes the previous (clears pendingOpReady_), so a fast - // poll keeps the busy-window to ~one tick and the install snappy. Applying on the - // main loop here (not the Improv task) keeps the factory/tree mutation off the - // serial task — the same discipline the credentials/deviceModel paths follow. + /// Apply a pending APPLY_OP through HttpServerModule::applyOp. Polled per-TICK (not loop1s) + /// because the installer pushes a burst of ops during provisioning and single-buffers them: + /// the Improv task refuses a new op until this consumes the previous (clears + /// pendingOpReady_), so a fast poll keeps the busy-window to ~one tick and the install + /// snappy. Applying on the main loop here (not the Improv task) keeps the factory/tree + /// mutation off the serial task — the same discipline the credentials/deviceModel paths + /// follow. A failed op can't travel back on the already-spent frame ack, so it's surfaced + /// over serial + in provision_status rather than looking like a clean install. void loop() override { if (pendingOpReady_.load(std::memory_order_acquire) && httpServerModule_) { // The Improv task already acked frame RECEIPT; the op is APPLIED here. A diff --git a/src/core/MoonModule.h b/src/core/MoonModule.h index 98b4db71..a38fcb9c 100644 --- a/src/core/MoonModule.h +++ b/src/core/MoonModule.h @@ -7,18 +7,18 @@ namespace mm { -// Peripheral: a module attached to SystemModule that bridges to the outside -// world — hardware or network — and is user-add/deletable (the firmware is the -// same whether or not the device has the peripheral wired). Covers both readers -// and writers: gyro/IMU + mic/line-in (in), relay/GPIO + Home Assistant push -// (out), and modules that do both. Read-vs-write is NOT a role distinction — -// direction and core affinity are per-module decisions (a peripheral may read, -// write, or both), so one role spans the category. Justified by that named -// roster, not one member (core grows slower than the domain — see CLAUDE.md). +/// A module's role for type identification (no RTTI needed) and for the UI's generic rendering. +/// Peripheral is a module attached to SystemModule that bridges to the outside world — hardware +/// or network — and is user-add/deletable (the firmware is the same whether or not the device has +/// the peripheral wired). It covers both readers and writers: gyro/IMU + mic/line-in (in), +/// relay/GPIO + Home Assistant push (out), and modules that do both. Read-vs-write is NOT a role +/// distinction — direction is a per-module decision, not a role split — so one role spans the +/// category, justified by that named roster, not one member (core grows slower than the domain, +/// see CLAUDE.md). enum class ModuleRole : uint8_t { Generic, Effect, Modifier, Driver, Layout, Layer, Peripheral }; -// Lowercase role name for JSON/API output. Single source of truth so the role -// string can't drift between /api/state and /api/types. +/// Lowercase role name for JSON/API output. Single source of truth so the role +/// string can't drift between /api/state and /api/types. inline const char* roleName(ModuleRole role) { switch (role) { case ModuleRole::Effect: return "effect"; @@ -31,6 +31,55 @@ inline const char* roleName(ModuleRole role) { } } +/// The base class for everything in the system — effects, modifiers, layouts, drivers, and +/// system services all inherit from MoonModule. It is the one deliberate class hierarchy: a +/// single virtual-dispatch boundary and shallow subclasses, so the UI can render any module +/// generically with zero per-module UI code. The design goal is the smallest possible base: +/// zero bytes of instance overhead beyond the vtable pointer and control variables (the type +/// name lives in flash, not per instance), because on an ESP32 without PSRAM dozens of modules +/// load at once and every byte counts. Field order is grouped 8/4/2/1-byte to minimise padding. +/// +/// **Lifecycle.** `setup()` / `teardown()` bracket the module's life; `loop()` / `loop20ms()` / +/// `loop1s()` are the three tick rates the Scheduler paces. Two build hooks sit apart from +/// `setup()`: `onBuildControls()` holds every `addX()` call and is idempotent + re-runnable (so a +/// Select changing the visible control set rebuilds cleanly), and `onBuildState()` is the single +/// derived-state hook (buffers, LUTs, the module's heap-byte report), reached at setup and via +/// `Scheduler::buildState()` whenever a control that changes physical dimensions fires +/// `controlChangeTriggersBuildState()`. This build sweep is what makes every config change apply +/// live, with no reboot. Controls bind by reference, so persisted values overlay the bound +/// variables before any `setup()` runs. +/// +/// **Parent/child.** Modules form a tree — parent/child only, no arbitrary DAG. A dynamic +/// children array plus `addChild()` / `removeChild()` / `replaceChildAt()` / `moveChildTo()` +/// live once in this base (containers do not override them); the array starts empty (zero +/// allocation for leaf modules) and grows on demand. Children are distinguished by `role()`, and +/// a container filters by role at the call site (a Layer ticks only Effects, not Modifiers). Two +/// virtuals keep UI tree-mutation policy on the device: `acceptsChildRoles()` (what a parent +/// offers in "+ add child") and `userEditable()` (whether the user may delete/replace this +/// module). Parents own their children's lifecycle and propagate every hook down — only +/// top-level modules register with the Scheduler. +/// +/// **Enabled.** Every module has an `enabled` flag (default true), toggled from the UI card +/// header and via `POST /api/control`. The Scheduler always calls the three loop hooks regardless +/// of `enabled`; each module decides what "disabled" means — a rendering module early-returns +/// (its buffer freezes) while a system module ignores the flag (`respectsEnabled()` false) so the +/// user can't lock themselves out. `onEnabled(bool)` fires once per transition for one-shot +/// start/stop work, instead of polling `enabled()` in the hot path. +/// +/// **Self-reporting.** Each module reports its own footprint and cost so the UI shows per-module +/// visibility at any depth: `classSize()` (set once at registration via `register_type<T>()`, no +/// per-class boilerplate), `dynamicBytes()` (heap set by `onBuildState()`), and `loopTimeUs()` +/// (average microseconds per tick over a 1-second window; `publishTiming()` recurses the tree +/// every second — parents time their children, the Scheduler times top-level modules). tickTimeUs +/// is the primary performance metric; FPS is derived as `1000000 / tickTimeUs`. `setStatus(msg, +/// severity)` carries a short user-facing message (Status / Warning / Error → ℹ️ / ⚠️ / ❌); the +/// slot stores a pointer with no copy, so callers pass a flash literal or a module-owned buffer. +/// `markDirty()` marks state touched so FilesystemModule can persist the subtree after a debounce. +/// +/// **Prior art:** MoonLight's Node +/// (https://github.com/ewowi/MoonLight/blob/main/src/MoonBase/Nodes.h) — a ~29-byte base + vtable +/// with no `std::string` members (fixed-size strings), `addControl()` binding to a class variable +/// by reference and storing a `uintptr_t`, and `classSize()` reporting the actual instance size. class MoonModule { public: // Allocate modules in PSRAM when available (ESP32) @@ -45,68 +94,63 @@ class MoonModule { MoonModule(MoonModule&&) = delete; MoonModule& operator=(MoonModule&&) = delete; - // Default lifecycle propagates to children. Override to add container-specific logic. - // - // For loop / loop20ms / loop1s, the default ticks every child that passes the same - // enabled gate the Scheduler applies to top-level modules (!respectsEnabled() || - // enabled() — i.e. tick when the module opted out of the gate, otherwise honour - // enabled()), and accumulates per-child timing the same way Scheduler does. Leaf - // modules (childCount_ == 0) pay one predicted-not-taken branch — sub-nanosecond. - // - // Override + chain convention for loop callbacks: parent work runs first, then - // chain to base to tick children (option A — parent prepares, children consume). - // Override + chain for setup runs the other way (chain to base first so children - // are initialised before the parent depends on them). teardown's base default - // reverse-iterates children; override and chain late so the parent shuts down its - // own state first. + /// Default lifecycle propagates to children. Override to add container-specific logic. + /// + /// For loop / loop20ms / loop1s, the default ticks every child that passes the same + /// enabled gate the Scheduler applies to top-level modules (!respectsEnabled() || + /// enabled() — tick when the module opted out of the gate, otherwise honour + /// enabled()), and accumulates per-child timing the same way Scheduler does. Leaf + /// modules (childCount_ == 0) pay one predicted-not-taken branch — sub-nanosecond. + /// + /// Override + chain convention for loop callbacks: parent work runs first, then + /// chain to base to tick children (option A — parent prepares, children consume). + /// Override + chain for setup runs the other way (chain to base first so children + /// are initialised before the parent depends on them). teardown's base default + /// reverse-iterates children; override and chain late so the parent shuts down its + /// own state first. virtual void setup() { for (uint8_t i = 0; i < childCount_; i++) children_[i]->setup(); } virtual void loop() { tickChildren(&MoonModule::loop); } virtual void loop20ms() { tickChildren(&MoonModule::loop20ms); } virtual void loop1s() { tickChildren(&MoonModule::loop1s); } virtual void teardown() { for (uint8_t i = childCount_; i > 0; i--) children_[i-1]->teardown(); } - // Called when enabled flips. Default no-op; override to start/stop sockets, free - // buffers, etc. The scheduler always invokes loop()/loop20ms()/loop1s() regardless - // of `enabled` — modules decide what disabled means by checking enabled() inside - // their loop fns or by stopping/starting their work in onEnabled(). + /// Called when enabled flips. Default no-op; override to start/stop sockets, free + /// buffers, etc. The scheduler always invokes loop()/loop20ms()/loop1s() regardless + /// of `enabled` — modules decide what disabled means by checking enabled() inside + /// their loop fns or by stopping/starting their work in onEnabled(). virtual void onEnabled(bool /*newEnabled*/) {} - // Control-change reactions form a three-tier split (mirrors MoonLight's - // onUpdate / requestMappings / onSizeChanged; see architecture.md § Rebuild - // propagation): - // - // 1. onUpdate(name) — cheap per-control reaction, runs on EVERY change. - // Recompute a small LUT, re-bind a socket, etc. - // 2. controlChangeTriggersBuildState — gate for the pipeline-wide onBuildState() sweep. - // Only true for controls that change physical - // dimensions or mapping shape (Layout, Modifier). - // 3. onBuildState() — build the module's derived state (buffers, LUTs) - // to match current control values, reached via - // Scheduler::buildState() when tier 2 returns true. - // - // Called after a control's value is written from the UI/API. `controlName` is the - // changed control's name (stable; points into the descriptor). Default no-op. + /// Cheap per-control reaction, tier 1 of the three-tier control-change split (mirrors + /// MoonLight's onUpdate / requestMappings / onSizeChanged; see architecture.md § Rebuild + /// propagation). Runs on EVERY change — recompute a small LUT, re-bind a socket, etc. + /// The other tiers are `controlChangeTriggersBuildState()` (tier 2, the gate for the + /// pipeline-wide sweep, true only for controls that change physical dimensions / mapping + /// shape) and `onBuildState()` (tier 3, build derived state, reached via + /// `Scheduler::buildState()` when tier 2 returns true). + /// + /// Called after a control's value is written from the UI/API. `controlName` is the + /// changed control's name (stable; points into the descriptor). Default no-op. virtual void onUpdate(const char* /*controlName*/) {} - // Whether a value change to one of this module's controls triggers the pipeline-wide - // onBuildState() sweep. Default false — most controls are values read in the hot - // path that need no realloc. Layout and Modifier override to return true (their - // controls change physical dimensions / LUT shape). Most overriders ignore the name - // and return true for every control they expose. + /// Whether a value change to one of this module's controls triggers the pipeline-wide + /// onBuildState() sweep. Default false — most controls are values read in the hot + /// path that need no realloc. Layout and Modifier override to return true (their + /// controls change physical dimensions / LUT shape). Most overriders ignore the name + /// and return true for every control they expose. virtual bool controlChangeTriggersBuildState(const char* /*controlName*/) const { return false; } - // onBuildControls MUST be idempotent and pure: only `controls_.clear()` + `controls_.addX()`. - // No platform queries, no I/O, no allocations. HttpServerModule calls it again whenever a - // Select control changes the visible control set, so a second invocation must produce - // exactly the same result for unchanged inputs. Conditional branches may depend on any - // member variable. + /// onBuildControls MUST be idempotent and pure: only `controls_.clear()` + `controls_.addX()`. + /// No platform queries, no I/O, no allocations. HttpServerModule calls it again whenever a + /// Select control changes the visible control set, so a second invocation must produce + /// exactly the same result for unchanged inputs. Conditional branches may depend on any + /// member variable. virtual void onBuildControls() { for (uint8_t i = 0; i < childCount_; i++) children_[i]->onBuildControls(); } - // Non-virtual helper: clear-and-rebuild for this module AND its descendants. The default - // onBuildControls cascades into children, so we must also clear their control lists first; - // otherwise the recursive append would duplicate every child's controls. Used after Select - // changes (in HttpServerModule) and anywhere else the conditional control set needs - // re-evaluation. + /// Non-virtual helper: clear-and-rebuild for this module AND its descendants. The default + /// onBuildControls cascades into children, so we must also clear their control lists first; + /// otherwise the recursive append would duplicate every child's controls. Used after Select + /// changes (in HttpServerModule) and anywhere else the conditional control set needs + /// re-evaluation. void rebuildControls() { clearControlsRecursive(); onBuildControls(); @@ -116,35 +160,35 @@ class MoonModule { for (uint8_t i = 0; i < childCount_; i++) children_[i]->clearControlsRecursive(); } - // Tier-3 of the control-change split (see onUpdate above): the module (re)allocates - // / recomputes whatever derived state it owns — an effect's heap, a Layer's mapping - // LUT, the Drivers output buffer. Default propagates to children. Reached via - // Scheduler::buildState() (whole-tree) when a tier-2 gate returns true. - // - // Same role as JUCE's `prepareToPlay` or UIKit's `layoutSubviews` — a framework-driven - // "set up your derived state for the current config" hook with a no-op default. The verb - // is "build" (not "rebuild") on purpose: the operation is idempotent and history-agnostic - // — it builds the correct state from current values whether or not it ran before, so boot - // and a later control change are the same call, not "build" then "rebuild". The whole - // chain shares the verb: controlChangeTriggersBuildState → Scheduler::buildState() → - // onBuildState(). Mirrors the onBuildControls precedent (build the surface vs build the - // state) and the canonical hooks (prepareToPlay/layoutSubviews never say "re" either). - // - // Intentionally coarse: each module builds its whole derived state, and the Scheduler - // sweeps the whole tree. That's fine because structural changes are rare and the builds - // are idempotent (e.g. FireEffect only reallocs when count != heatCount_). If a module - // ever grows two independently-buildable aspects where one control touches only one of - // them (e.g. `width` reshapes a LUT but `gamma` only re-tints a cache, both expensive), - // the cheapest upgrade is to forward the changed control name — - // `onBuildState(const char* changedControl)` — and branch inside. The tier-2 gate - // (controlChangeTriggersBuildState) already carries the name, so it's a one-parameter change. - // Don't add it pre-emptively; no module needs the distinction today. + /// Tier-3 of the control-change split (see onUpdate above): the module (re)allocates + /// / recomputes whatever derived state it owns — an effect's heap, a Layer's mapping + /// LUT, the Drivers output buffer. Default propagates to children. Reached via + /// Scheduler::buildState() (whole-tree) when a tier-2 gate returns true. + /// + /// Same role as JUCE's `prepareToPlay` or UIKit's `layoutSubviews` — a framework-driven + /// "set up your derived state for the current config" hook with a no-op default. The verb + /// is "build" (not "rebuild") on purpose: the operation is idempotent and history-agnostic + /// — it builds the correct state from current values whether or not it ran before, so boot + /// and a later control change are the same call, not "build" then "rebuild". The whole + /// chain shares the verb: controlChangeTriggersBuildState → Scheduler::buildState() → + /// onBuildState(). Mirrors the onBuildControls precedent (build the surface vs build the + /// state) and the canonical hooks (prepareToPlay/layoutSubviews never say "re" either). + /// + /// Intentionally coarse: each module builds its whole derived state, and the Scheduler + /// sweeps the whole tree. That's fine because structural changes are rare and the builds + /// are idempotent (FireEffect only reallocs when count != heatCount_). If a module + /// ever grows two independently-buildable aspects where one control touches only one of + /// them (`width` reshapes a LUT but `gamma` only re-tints a cache, both expensive), + /// the cheapest upgrade is to forward the changed control name — + /// `onBuildState(const char* changedControl)` — and branch inside. The tier-2 gate + /// (controlChangeTriggersBuildState) already carries the name, so it's a one-parameter change. + /// Don't add it pre-emptively; no module needs the distinction today. virtual void onBuildState() { for (uint8_t i = 0; i < childCount_; i++) children_[i]->onBuildState(); } - // Read this module's first output light as RGB into out[3], returning true if it has - // one. Domain-neutral seam (core declares it, the output-owning module overrides): - // the WLED-compatibility shim uses it to tint the app's device card with the live - // first-LED colour. Default: no output → false. + /// Read this module's first output light as RGB into out[3], returning true if it has + /// one. Domain-neutral seam (core declares it, the output-owning module overrides): + /// the WLED-compatibility shim uses it to tint the app's device card with the live + /// first-LED colour. Default: no output → false. virtual bool firstOutputRgb(uint8_t /*out*/[3]) const { return false; } const char* name() const { return name_; } @@ -156,12 +200,12 @@ class MoonModule { name_[len] = 0; } - // typeName is the stable factory key (e.g. "NoiseEffect"), set once by ModuleFactory. - // Stored as `const char*` pointing at the factory's string literal — zero per-instance - // copy, lives in flash. Caller must pass a string with static lifetime (string literal - // or factory-owned storage); do not pass stack-local or temporary buffers. - // Distinct from name() which is a per-instance human label and may be overridden - // ("Noise" instead of "NoiseEffect"); typeName() stays the factory key. + /// typeName is the stable factory key (such as "NoiseEffect"), set once by ModuleFactory. + /// Stored as `const char*` pointing at the factory's string literal — zero per-instance + /// copy, lives in flash. Caller must pass a string with static lifetime (string literal + /// or factory-owned storage); do not pass stack-local or temporary buffers. + /// Distinct from name() which is a per-instance human label and may be overridden + /// ("Noise" instead of "NoiseEffect"); typeName() stays the factory key. const char* typeName() const { return typeName_; } void setTypeName(const char* tn) { typeName_ = tn ? tn : ""; } @@ -172,15 +216,15 @@ class MoonModule { onEnabled(e); } - // Whether the Scheduler should honor `enabled()` for this module's loop callbacks. - // Default true — disabled modules don't have their loop fns called. Override to - // return false for system modules that must keep running regardless (HttpServer, - // Network, Filesystem) so the user can re-enable other modules through them. + /// Whether the Scheduler should honor `enabled()` for this module's loop callbacks. + /// Default true — disabled modules don't have their loop fns called. Override to + /// return false for system modules that must keep running regardless (HttpServer, + /// Network, Filesystem) so the user can re-enable other modules through them. virtual bool respectsEnabled() const { return true; } - // Dirty flag — set by HttpServerModule when a control changes. A future persistence layer - // (or any consumer interested in "this module's state has been touched") can observe it - // and clear it after handling. + /// Dirty flag — set by HttpServerModule when a control changes. FilesystemModule (or any + /// consumer interested in "this module's state has been touched") observes it in loop1s() + /// and clears it after persisting. bool dirty() const { return dirty_; } void markDirty() { dirty_ = true; } void clearDirty() { dirty_ = false; } @@ -188,55 +232,55 @@ class MoonModule { MoonModule* parent() const { return parent_; } void setParent(MoonModule* p) { parent_ = p; } - // Marks this module as wired-by-code rather than wired-by-persistence. The - // FilesystemModule's applyNode trim loop preserves code-wired children even - // when the on-disk file doesn't describe them — the upgrade-day case where - // a new firmware revision adds a code-created child (e.g. ImprovProvisioning - // as a child of NetworkModule) whose existence the device's saved Network.json - // predates. Without this flag the child would get trimmed on every boot. - // - // Convention: only main.cpp's boot wiring calls markWiredByCode(). Children - // added via the HTTP add-module API or recreated by applyNode's factory call - // stay unmarked — those are user/persistence-driven and should follow the - // file's tree shape exactly. + /// Marks this module as wired-by-code rather than wired-by-persistence. The + /// FilesystemModule's applyNode trim loop preserves code-wired children even + /// when the on-disk file doesn't describe them — the upgrade-day case where + /// a new firmware revision adds a code-created child (ImprovProvisioning + /// as a child of NetworkModule) whose existence the device's saved Network.json + /// predates. Without this flag the child would get trimmed on every boot. + /// + /// Convention: only main.cpp's boot wiring calls markWiredByCode(). Children + /// added via the HTTP add-module API or recreated by applyNode's factory call + /// stay unmarked — those are user/persistence-driven and should follow the + /// file's tree shape exactly. void markWiredByCode() { wiredByCode_ = true; } bool isWiredByCode() const { return wiredByCode_; } ControlList& controls() { return controls_; } const ControlList& controls() const { return controls_; } - // Role for type identification (no RTTI needed) + /// Role for type identification (no RTTI needed). virtual ModuleRole role() const { return ModuleRole::Generic; } - // Curated emoji tags for the module picker's chip filter — extras beyond the - // role chip (which the UI derives from role() on its own). A short string of - // emoji, e.g. "🔥" or "🌊💧". Default "" — most modules add nothing. The - // return value is a flash string literal; no per-instance RAM cost. + /// Curated emoji tags for the module picker's chip filter — extras beyond the + /// role chip (which the UI derives from role() on its own). A short string of + /// emoji, such as "🔥" or "🌊💧". Default "" — most modules add nothing. The + /// return value is a flash string literal; no per-instance RAM cost. virtual const char* tags() const { return ""; } - // Comma-separated role names this module accepts as user-added children - // (e.g. "effect,modifier"). "" = accepts none — the default, covering - // leaf modules and fixed-shape containers. A container overrides this to - // tell the UI's "+ add child" picker what to offer. Comma-separated - // string (not a bitmask) so it serialises straight into /api/types and a - // multi-role parent (Layer → "effect,modifier") needs no enum-set type. - // This is what makes the UI domain-neutral: it reads the accepted roles - // from here instead of hardcoding which module types are containers. - // Like tags(), overrides MUST return static-lifetime storage (a string - // literal or static const char[]): ModuleFactory::registerType<T>() probes - // the type once and stores this pointer in the static type registry, so a - // pointer to a temporary or member buffer would dangle. + /// Comma-separated role names this module accepts as user-added children + /// ("effect,modifier"). "" = accepts none — the default, covering + /// leaf modules and fixed-shape containers. A container overrides this to + /// tell the UI's "+ add child" picker what to offer. Comma-separated + /// string (not a bitmask) so it serialises straight into /api/types and a + /// multi-role parent (Layer → "effect,modifier") needs no enum-set type. + /// This is what makes the UI domain-neutral: it reads the accepted roles + /// from here instead of hardcoding which module types are containers. + /// Like tags(), overrides MUST return static-lifetime storage (a string + /// literal or static const char[]): ModuleFactory::registerType`<T>`() probes + /// the type once and stores this pointer in the static type registry, so a + /// pointer to a temporary or member buffer would dangle. virtual const char* acceptsChildRoles() const { return ""; } - // Whether the user may delete or replace this module from the UI. Default - // true — most user-added modules are freely editable. A load-bearing or - // code-wired child overrides to false (e.g. PreviewDriver, whose deletion - // would kill the live 3D preview). The flag lives on the CHILD because the - // child knows whether it's safe to remove; the parent only decides what - // can be added (acceptsChildRoles). Surfaced per-instance in /api/state. + /// Whether the user may delete or replace this module from the UI. Default + /// true — most user-added modules are freely editable. A load-bearing or + /// code-wired child overrides to false (PreviewDriver, whose deletion + /// would kill the live 3D preview). The flag lives on the CHILD because the + /// child knows whether it's safe to remove; the parent only decides what + /// can be added (acceptsChildRoles). Surfaced per-instance in /api/state. virtual bool userEditable() const { return true; } - // Generic children — grows on demand, only allocates during setup + /// Generic children — grows on demand, only allocates during setup. bool addChild(MoonModule* child) { if (!child) return false; if (childCount_ == childCapacity_) { @@ -264,8 +308,8 @@ class MoonModule { return false; } - // Replace child at position i with fresh. Caller owns lifecycle of the removed - // (returned) child — teardown + delete. Returns nullptr if i is out of range. + /// Replace child at position i with fresh. Caller owns lifecycle of the removed + /// (returned) child — teardown + delete. Returns nullptr if i is out of range. MoonModule* replaceChildAt(uint8_t i, MoonModule* fresh) { if (i >= childCount_ || !fresh) return nullptr; MoonModule* old = children_[i]; @@ -275,9 +319,9 @@ class MoonModule { return old; } - // Move child to absolute position newIndex (0..childCount-1). Intermediate siblings - // shift toward the vacated slot. Returns false if child isn't found, newIndex is out - // of range, or the move is a no-op (already at newIndex). + /// Move child to absolute position newIndex (0..childCount-1). Intermediate siblings + /// shift toward the vacated slot. Returns false if child isn't found, newIndex is out + /// of range, or the move is a no-op (already at newIndex). bool moveChildTo(MoonModule* child, uint8_t newIndex) { if (newIndex >= childCount_) return false; for (uint8_t i = 0; i < childCount_; i++) { @@ -299,23 +343,26 @@ class MoonModule { uint8_t childCount() const { return childCount_; } MoonModule* child(uint8_t i) const { return i < childCount_ ? children_[i] : nullptr; } - // Per-module memory reporting + /// Per-module memory reporting: classSize() is the instance size (set once at registration), + /// dynamicBytes() the heap this module allocated (set by onBuildState()). size_t classSize() const { return classSize_ > 0 ? classSize_ : sizeof(MoonModule); } void setClassSize(size_t s) { classSize_ = s; } size_t dynamicBytes() const { return dynamicBytes_; } void setDynamicBytes(size_t b) { dynamicBytes_ = b; } - // Per-module status slot. A short user-facing message the module wants the - // user to see right now — NetworkModule writes "Eth: 192.168.1.210", Layer - // writes "buffer reduced — not enough memory". The pointer is owned by the - // caller (flash literal or a module-owned char buffer); the slot doesn't - // copy. `nullptr` = nothing to show. - // - // `severity` qualifies the message so the UI can pick the right emoji: - // Status ℹ️ — neutral info, current state ("connected"). - // Warning ⚠️ — silent degradation ("buffer reduced"). - // Error ❌ — something failed ("WiFi auth failed"). - enum class Severity : uint8_t { Status, Warning, Error }; + /// Per-module status slot. A short user-facing message the module wants the + /// user to see right now — NetworkModule writes "Eth: 192.168.1.210", Layer + /// writes "buffer reduced — not enough memory". The pointer is owned by the + /// caller (flash literal or a module-owned char buffer); the slot doesn't + /// copy. `nullptr` = nothing to show. `severity` qualifies the message so the + /// UI can pick the right emoji (Status ℹ️ neutral info, Warning ⚠️ silent + /// degradation, Error ❌ something failed). Emitted in /api/state + /api/system + /// only when set. + enum class Severity : uint8_t { + Status, ///< ℹ️ neutral info, current state ("connected") + Warning, ///< ⚠️ silent degradation ("buffer reduced") + Error, ///< ❌ something failed ("WiFi auth failed") + }; const char* status() const { return status_; } Severity severity() const { return severity_; } void setStatus(const char* msg, Severity sev = Severity::Status) { @@ -324,11 +371,13 @@ class MoonModule { } void clearStatus() { status_ = nullptr; severity_ = Severity::Status; } - // Per-module timing: parents time children, Scheduler times top-level + /// Per-module timing: parents time children, Scheduler times top-level modules. + /// loopTimeUs() is the average microseconds per tick over the last 1-second window. uint32_t loopTimeUs() const { return loopTimeUs_; } void addAccumUs(uint32_t us) { accumUs_ += us; } - // Called by Scheduler every ~1 second. Recurses into children. + /// Called by Scheduler every ~1 second. Averages the accumulated tick time and recurses + /// into children. void publishTiming(uint32_t frameCount) { loopTimeUs_ = frameCount > 0 ? accumUs_ / frameCount : 0; accumUs_ = 0; @@ -340,12 +389,12 @@ class MoonModule { protected: ControlList controls_; - // Shared body for the loop / loop20ms / loop1s base defaults. Iterates children, - // gates each by the same rule the Scheduler applies to top-level modules - // (!respectsEnabled() || enabled() — children that opted out of the enabled - // gate keep ticking; the rest tick only when enabled), dispatches the same - // callback, and accumulates per-child timing. Pulled out so the three base - // defaults stay one-liners and the gating + timing rule lives in one place. + /// Shared body for the loop / loop20ms / loop1s base defaults. Iterates children, + /// gates each by the same rule the Scheduler applies to top-level modules + /// (!respectsEnabled() || enabled() — children that opted out of the enabled + /// gate keep ticking; the rest tick only when enabled), dispatches the same + /// callback, and accumulates per-child timing. Pulled out so the three base + /// defaults stay one-liners and the gating + timing rule lives in one place. void tickChildren(void (MoonModule::*fn)()) { for (uint8_t i = 0; i < childCount_; i++) { MoonModule* c = children_[i]; diff --git a/src/core/NetworkModule.h b/src/core/NetworkModule.h index 84598a60..d7ca5eee 100644 --- a/src/core/NetworkModule.h +++ b/src/core/NetworkModule.h @@ -11,33 +11,106 @@ namespace mm { +/// Manages all device connectivity with an automatic priority cascade: Ethernet → WiFi +/// STA → WiFi AP. One MoonModule, one UI card — the user sees "Network", not three +/// separate technologies. ESP32-specific (and Teensy later); desktop and RPi use OS-level +/// networking and load no NetworkModule. +/// +/// **Priority cascade:** Ethernet is always preferred (hardware detected, cable plugged), +/// WiFi STA is next (SSID configured, Ethernet unavailable), WiFi AP is the last resort +/// (STA fails or no SSID). When a higher-priority connection becomes available, lower ones +/// are torn down to reclaim memory; when a higher-priority connection drops, the next +/// activates automatically. The cascade tries each interface unconditionally and relies on +/// the platform init calls to fail fast when hardware is absent — `platform::ethInit()` +/// returns false without a PHY, and the WiFi paths return false on chips without a radio, +/// so no interface hangs waiting on missing hardware. +/// +/// **AP shutdown delay:** when STA connects successfully, AP stays active for ~10 s (with +/// a UI message) before tearing down, giving the user time to reconnect via STA. AP always +/// uses the fixed IP `4.3.2.1` — easy to remember, avoids 192.168.x.x conflicts with home +/// routers. +/// +/// **State machine:** `State` (Idle, WaitingEth, WaitingSta, ConnectedEth, ConnectedSta, +/// AP) is driven from `loop1s()`. The `mode` control mirrors the state in plain language +/// and is always present, even on the Ethernet-only build. A late-appearing interface +/// (slow DHCP, cable plugged in after boot, saved WiFi credentials) is promoted from Idle +/// / AP / ConnectedSta by the periodic upgrade checks — no reboot. +/// +/// **Ethernet:** which PHY *driver* is compiled in is per chip (classic/P4 carry the +/// internal-EMAC RMII driver, the S3 the W5500 SPI driver; a `MM_NO_ETH` build stubs +/// `ethInit()` to return false). *Which* PHY a board uses and *on which pins* is runtime +/// config — the `ethType` + pin controls, set per board in the device-model catalog and +/// seeded from the per-chip default in `platform_config.h`. A W5500 change applies live +/// (the SPI driver tears down and re-inits, no reboot); an RMII change saves and applies +/// on the next boot (the EMAC/clock teardown is fiddlier). +/// +/// **mDNS:** included here (not a separate module). Registers the deviceName on whichever +/// interface is active and re-registers when the active interface changes or the name is +/// renamed live. Uses ESP-IDF's `mdns_init()` / `mdns_hostname_set()`. +/// +/// **Device name:** the network name is owned solely by SystemModule; this module only +/// READS it (see `readDeviceName`), and it is the single identity behind the mDNS +/// `<name>.local`, the SoftAP SSID, and the DHCP hostname — so a device shows one name +/// everywhere. +/// +/// **`MM_IP=` boot token:** `currentIp()` writes the device's current LAN IP as octets; +/// main.cpp formats it and appends a machine-parseable `MM_IP=<ip>` token to its +/// once-per-second tick line whenever there is an IP. The web installer reads this from +/// the boot serial log right after flashing to auto-add the device to "Your devices" — +/// timing-independent because the token rides the already-periodic tick line. Deliberately +/// IP-only; once the installer has the IP it reads everything else from the live REST API. +/// +/// **Memory:** the network stack cost varies by mode (Ethernet ~20 KB, STA ~40 KB, AP +/// ~30 KB, STA+AP during the shutdown delay ~60 KB, fully reclaimed after teardown). This +/// is why NetworkModule registers with the Scheduler BEFORE the light pipeline: network +/// memory is claimed first so the light pipeline's adaptive allocation sees the real +/// remaining heap. On a mode change the transition sequence checks heap, tears down light +/// buffers first if heap is tight (display goes dark temporarily — acceptable, a crash is +/// not), starts the new mode, then re-runs `scheduler_->buildState()` so allocation uses +/// whatever heap remains. Reported via the standard per-module system; dynamicBytes updates +/// after each mode change. +/// +/// **Ethernet-only build:** `esp32-eth` compiles WiFi out entirely +/// (`platform::hasWiFi == false`), branched via `if constexpr`. The cascade is +/// Ethernet-only (no STA/AP states reachable), the `ssid` / `password` controls are not +/// bound, but the `addressing` selector, static-IP controls, and `mDNS` toggle remain. The +/// `ssid_` / `password_` buffers still exist (unconditional struct layout keeps persistence +/// stable), simply never displayed or used. +/// +/// **Security:** AP mode is open (no password) — a fallback for initial setup only. The +/// STA password is stored in the controls. No HTTPS — an embedded device on the local +/// network only. +/// +/// **Prior art:** MoonLight — mDNS hostname advertising, REST API for network config, +/// credentials persisted to SPIFFS. ESP-IDF — `esp_wifi.h`, `mdns.h`, `esp_netif.h`, +/// `esp_event.h`. class NetworkModule : public MoonModule { public: void setScheduler(Scheduler* s) { scheduler_ = s; } void setSystemModule(SystemModule* s) { systemModule_ = s; } - // External entry-point for setting WiFi credentials at runtime — used by - // ImprovProvisioningModule when the browser/CLI pushes new credentials over - // USB-serial. Writes the same buffers the AP-fallback UI flow writes via - // POST /api/control on `ssid` / `password`, then drives a clean transition - // into State::WaitingSta so loop1s() takes over and either reports - // connected (onConnected) or falls back to AP after the 10 s timeout. - // - // Why the explicit AP→STA tear-down (rather than just calling wifiStaInit - // and letting esp_wifi_set_mode handle the mode change): in AP-mode the - // platform layer's wifiInitDone_ flag is true, which makes ensureWifiInit - // return early without registering the IP_EVENT_STA_GOT_IP handler. Without - // that handler the wifiStaConnected_ flag never flips, the WaitingSta - // state never sees the STA come up, and the device sits in limbo with - // 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 — a weak-powered board / WiFi module (thin LDO, marginal - // USB supply) browns out and fails WiFi auth at full power, so the cap has to - // be in place for the association attempt. + /// External entry-point for setting WiFi credentials at runtime — used by + /// ImprovProvisioningModule when the browser/CLI pushes new credentials over + /// USB-serial. Writes the same buffers the AP-fallback UI flow writes via + /// POST /api/control on `ssid` / `password`, then drives a clean transition + /// into `State::WaitingSta` so loop1s() takes over and either reports + /// connected (onConnected) or falls back to AP after the 10 s timeout. + /// + /// Why the explicit AP→STA tear-down (rather than just calling wifiStaInit + /// and letting esp_wifi_set_mode handle the mode change): in AP-mode the + /// platform layer's wifiInitDone_ flag is true, which makes ensureWifiInit + /// return early without registering the IP_EVENT_STA_GOT_IP handler. Without + /// that handler the wifiStaConnected_ flag never flips, the WaitingSta + /// state never sees the STA come up, and the device sits in limbo with + /// 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 — a weak-powered board / WiFi module (thin LDO, marginal + /// USB supply) browns out and fails 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; @@ -98,9 +171,9 @@ class NetworkModule : public MoonModule { } } - // Networking is infrastructure — keep the cascade ticking even when the user - // toggled "enabled" off, otherwise the device would silently drop off the LAN - // and become unreachable. + /// Networking is infrastructure — keep the cascade ticking even when the user + /// toggled "enabled" off, otherwise the device would silently drop off the LAN + /// and become unreachable. bool respectsEnabled() const override { return false; } void setup() override { @@ -651,17 +724,17 @@ class NetworkModule : public MoonModule { } public: - // Write the current LAN IP as octets into out[0..3] (all-zero = not connected). - // Octets, not a string: the IP's canonical form is uint8_t[4] (matching the - // static-IP controls and formatDottedQuad), and no IP string is held as state — - // the IP already lives as the netif's binding, so duplicating it into a member - // would just waste RAM. Callers that need text format with formatDottedQuad at - // their boundary. Read by main.cpp's per-second tick line, which appends it as a - // stable `MM_IP=<ip>` token for the web installer's post-flash serial read — - // riding the already-periodic tick line means the IP re-emits every second - // (timing-independent: DHCP can take several seconds — measured ~7s on the - // P4-NANO — and the installer reopens the port at its own pace, so a one-shot - // connect-time line is easy to miss). + /// Write the current LAN IP as octets into out[0..3] (all-zero = not connected). + /// Octets, not a string: the IP's canonical form is `uint8_t[4]` (matching the + /// static-IP controls and formatDottedQuad), and no IP string is held as state — + /// the IP already lives as the netif's binding, so duplicating it into a member + /// would just waste RAM. Callers that need text format with formatDottedQuad at + /// their boundary. Read by main.cpp's per-second tick line, which appends it as a + /// stable `MM_IP=<ip>` token for the web installer's post-flash serial read — + /// riding the already-periodic tick line means the IP re-emits every second + /// (timing-independent: DHCP can take several seconds — measured ~7s on the + /// P4-NANO — and the installer reopens the port at its own pace, so a one-shot + /// connect-time line is easy to miss). void currentIp(uint8_t out[4]) const { out[0] = out[1] = out[2] = out[3] = 0; if (state_ == State::ConnectedEth) platform::ethGetIPv4(out); @@ -671,14 +744,14 @@ class NetworkModule : public MoonModule { } private: - // The device's network name is owned solely by SystemModule; NetworkModule only - // READS it. This is the single identity behind every network name — the mDNS - // `<name>.local`, the SoftAP SSID, and the DHCP hostname are all this exact string, - // so a device shows one name everywhere. SystemModule guarantees it is a valid, - // non-empty hostname (sanitised + MAC-fallback in its setup/loop1s). Read through - // this one null-guard (systemModule_ is wired at boot; "" if somehow unwired — the - // platform name setters no-op on an empty string). NOT a deviceName of our own: - // it's SystemModule's, fetched. + /// The device's network name is owned solely by SystemModule; NetworkModule only + /// READS it. This is the single identity behind every network name — the mDNS + /// `<name>.local`, the SoftAP SSID, and the DHCP hostname are all this exact string, + /// so a device shows one name everywhere. SystemModule guarantees it is a valid, + /// non-empty hostname (sanitised + MAC-fallback in its setup/loop1s). Read through + /// this one null-guard (systemModule_ is wired at boot; "" if somehow unwired — the + /// platform name setters no-op on an empty string). NOT a deviceName of our own: + /// it's SystemModule's, fetched. const char* readDeviceName() const { return systemModule_ ? systemModule_->deviceName() : ""; } diff --git a/src/core/Scheduler.h b/src/core/Scheduler.h index 4f73f83f..f23d0e74 100644 --- a/src/core/Scheduler.h +++ b/src/core/Scheduler.h @@ -1,15 +1,5 @@ #pragma once -// Scheduler — owns the top-level module list, runs the 4-phase boot, drives the -// per-tick loop callbacks, and provides tree-walk utilities (delete, name-uniquify). -// -// Boot phases: see setup() comment in Scheduler.cpp. -// Tick: gates each top-level module by enabled() / respectsEnabled() and dispatches -// loop / loop20ms / loop1s. Per-second window averages the tick time and publishes -// each module's loop time. -// -// This is the .h interface. Bodies live in Scheduler.cpp. - #include "core/MoonModule.h" #include <array> @@ -17,12 +7,50 @@ namespace mm { +/// Domain-neutral orchestrator that owns the top-level module list, runs the 4-phase +/// boot, drives the per-tick loop callbacks, and provides tree-walk utilities (delete, +/// name-uniquify). +/// +/// **Boot phases:** see the `setup()` comment in Scheduler.cpp. +/// +/// **Tick:** gates each top-level module by `enabled()` / `respectsEnabled()` and +/// dispatches `loop` / `loop20ms` / `loop1s`. A per-second window averages the tick +/// time and publishes each module's loop time. +/// +/// **Loop rates:** three cadences cover every module. `loop()` is the hot path for +/// effects and drivers, called every iteration — the Scheduler handles pacing (yielding +/// to other tasks between iterations via `taskYIELD()` on ESP32, an optional sleep on +/// desktop). `loop20ms()` runs every ~20 ms for UI updates, control reads, and network +/// polling. `loop1s()` runs every ~1 second for diagnostics, reconnects, and +/// housekeeping. Not every module needs `loop()`: system modules (HTTP, WiFi) use +/// `loop20ms()` or `loop1s()` only. +/// +/// **Timing:** effects animate off a synchronized clock (millis from the platform), so +/// the visual speed is frame-rate independent — the same at 30 fps and 60 fps. For +/// multi-device sync a leader synchronizes this clock across devices; no frame counter +/// is needed. +/// +/// **Execution model:** `tick()` runs every top-level module inline, in one loop, in +/// declared order; each module then drives its own children. There is no per-module +/// task and no core-affinity field — a single render loop, not a task-per-module fan-out. +/// +/// **Module ordering:** child modules run in their declared order within the parent, +/// and top-level modules also run in declared order. The UI supports reordering, backed +/// by the scheduler. Relationships are parent/child only — there is no arbitrary +/// dependency graph. +/// +/// **Prior art:** MoonLight's `effectTask` / `svelteTask` — two FreeRTOS tasks (effects +/// on core 1, system/drivers on core 0) with a per-node `loop()` every frame and a +/// `loop20ms()` for slow updates. +/// +/// This is the `.h` interface; bodies live in Scheduler.cpp. class Scheduler { public: - // Function-pointer hook invoked between phase 1 (onBuildControls) and phase 3 (setup). - // Used by FilesystemModule to overlay persisted control values onto bound variables - // before modules' setup() runs. Scheduler stays independent of FilesystemModule's type - // (no circular include). Wired in via setLoadAllHook from main.cpp. + /// Function-pointer hook invoked between phase 1 (`onBuildControls`) and phase 3 + /// (`setup`). Used by FilesystemModule to overlay persisted control values onto bound + /// variables before modules' setup() runs. Scheduler stays independent of + /// FilesystemModule's type (no circular include). Wired in via setLoadAllHook from + /// main.cpp. using LoadAllFn = void(*)(Scheduler*); void setLoadAllHook(LoadAllFn fn) { loadAllHook_ = fn; } @@ -41,18 +69,18 @@ class Scheduler { static void deleteTree(MoonModule* mod); - // Ensure `mod`'s name is tree-globally unique. If something else already uses - // the same name, suffix with " 2", " 3", … until unique. Caller must have - // placed `mod` in the tree already (otherwise the lookup wouldn't see it). - // See Scheduler.cpp for the why and the name-length cap. + /// Ensure `mod`'s name is tree-globally unique. If something else already uses + /// the same name, suffix with " 2", " 3", … until unique. Caller must have + /// placed `mod` in the tree already (otherwise the lookup wouldn't see it). + /// See Scheduler.cpp for the why and the name-length cap. void ensureUniqueName(MoonModule* mod); - // Walk the whole tree and disambiguate every duplicated name. First - // occurrence keeps its name; later ones get " 2", " 3", … suffixes. - // Cold-path: called once after persistence load in setup(). + /// Walk the whole tree and disambiguate every duplicated name. First + /// occurrence keeps its name; later ones get " 2", " 3", … suffixes. + /// Cold-path: called once after persistence load in setup(). void deduplicateNamesInTree(); - // First module in tree-walk order with this name, or nullptr if none. + /// First module in tree-walk order with this name, or nullptr if none. MoonModule* firstByName(const char* name); private: diff --git a/src/core/SystemModule.h b/src/core/SystemModule.h index 3292b8b4..a2382d2a 100644 --- a/src/core/SystemModule.h +++ b/src/core/SystemModule.h @@ -10,20 +10,70 @@ namespace mm { +/// System-level diagnostics and device identity — always loaded, always visible in the +/// UI. Surfaces the live tick metrics (uptime, fps, tick time, free heap, PSRAM, largest +/// allocatable block), the static hardware facts (chip, SDK/IDF version, flash size, +/// boot/reset reason, WiFi co-processor state), and owns the device's identity: its +/// network name and its physical-hardware model. +/// +/// **Controls (ordered by change frequency):** +/// - *Dynamic (every second):* `uptime` (progress), `fps` (derived from the Scheduler's +/// tick time), `tickTimeUs` (average tick, microseconds), `heap` (progress: used / +/// total internal), `psram` (progress: used / total, only when present), `maxBlock` +/// (largest contiguous allocatable block). +/// - *Configurable:* `deviceName` (default `MM-XXXX`, XXXX = last 4 hex of the MAC) and +/// `deviceModel` (display-only in the UI, pushed by tooling). +/// - *Static (set at boot):* `chip`, `sdk`, `flash`, `bootReason`, and `wifiCoproc` +/// (only on boards whose radio is a separate chip). On desktop the hardware-specific +/// fields read "desktop" / "N/A". +/// +/// **Device name:** `deviceName` is the single network identity across the system — +/// NetworkModule uses it as the mDNS hostname (`<name>.local`), the SoftAP SSID, and the +/// DHCP hostname, and MoonDeck shows it in the device list. It is coerced to a valid, +/// non-empty hostname every tick (sanitize + MAC fallback) so whatever the user typed or +/// persistence restored, a live rename propagates everywhere within one tick — see +/// `loop1s()`. The default `MM-XXXX` derives from the last 4 hex of the MAC. +/// +/// **Device model:** `deviceModel` is the physical-hardware identity (which product this +/// is, for example `Olimex ESP32-Gateway Rev G`) — the entry name from the device-model +/// catalog. The device cannot self-identify its hardware, so this is pushed by tooling: +/// the web installer sends it as an `APPLY_OP` `set` op during provisioning, or MoonDeck +/// over HTTP `/api/control`. The printable-ASCII rule (1..31 chars, 0x20–0x7E, no NUL) +/// is a per-control validator on the descriptor, so every write path (HTTP, serial +/// APPLY_OP, persistence load) runs it in the backend. See `validateDeviceModel`. +/// +/// **`bootReason`:** the human-readable reset reason from `platform::resetReason()` +/// (`POWERON`, `SW`, `PANIC`, `INT_WDT`, `TASK_WDT`, `BROWNOUT`, `DEEPSLEEP`; desktop +/// always reports `OK`). The UI flags the reboot button with a red border when the value +/// is one of PANIC / INT_WDT / TASK_WDT / BROWNOUT, indicating the prior boot ended +/// unexpectedly. +/// +/// **PSRAM detection is derived, not flagged:** ESP-IDF auto-detects the PSRAM chip at +/// boot and merges its pool into the heap allocator, after which `totalHeap()` reports +/// internal + PSRAM combined while `totalInternalHeap()` reports internal only — so +/// `totalHeap > totalInternal` is the "PSRAM present" signal. Boards without PSRAM skip +/// the `psram` control naturally, with no per-platform code path. +/// +/// **Children:** accepts user-added Peripheral children (sensors, actuators) through the +/// generic MoonModule add/replace/delete + persistence path — the same firmware runs +/// with or without them. +/// +/// **Prior art:** MoonLight — system diagnostics via REST API; device name used for +/// mDNS. class SystemModule : public MoonModule { public: void setScheduler(Scheduler* s) { scheduler_ = s; } - // Diagnostics keep ticking regardless — disabling System hides uptime/heap/fps - // from the UI for no good reason, and the user can't easily re-enable. + /// Diagnostics keep ticking regardless — disabling System hides uptime/heap/fps + /// from the UI for no good reason, and the user can't easily re-enable. bool respectsEnabled() const override { return false; } - // Accepts user-added Peripheral children (sensors, actuators — bridges to - // hardware/network the user solders on or off). The same firmware runs with - // or without them, so the user adds/deletes them at runtime; the add/replace/ - // delete + persistence machinery is the generic MoonModule path. (The deviceModel - // identity is a SystemModule control above, not a child module — SystemModule owns - // the device's identity, name + model, directly.) + /// Accepts user-added Peripheral children (sensors, actuators — bridges to + /// hardware/network the user solders on or off). The same firmware runs with + /// or without them, so the user adds/deletes them at runtime; the add/replace/ + /// delete + persistence machinery is the generic MoonModule path. (The deviceModel + /// identity is a SystemModule control above, not a child module — SystemModule owns + /// the device's identity, name + model, directly.) const char* acceptsChildRoles() const override { return "peripheral"; } void setup() override { @@ -193,21 +243,24 @@ class SystemModule : public MoonModule { MoonModule::loop1s(); } + /// The device's network identity (mDNS hostname, SoftAP SSID, DHCP hostname all + /// derive from it). Guaranteed a valid, non-empty hostname — coerced every tick. const char* deviceName() const { return deviceName_; } + /// The physical-hardware identity (device-model catalog entry name), pushed by tooling. const char* deviceModel() const { return deviceModel_; } - // Per-control validator for `deviceModel`, applied on EVERY write path (HTTP - // /api/control, APPLY_OP over serial, persistence load) via ControlDescriptor::validate. - // Accepts 1..31 chars, ASCII-printable (0x20–0x7E), no embedded NUL. The printable floor - // rejects control bytes / NULs that would corrupt downstream consumers — JSON - // serialization (control bytes need \u escaping at best, break naive emitters at worst), - // the device UI (rendered verbatim; a BEL/ESC would mangle the page), and C-string - // handling (no embedded NUL → strlen/strcpy round-trip cleanly). Printable ASCII still - // contains `"` and `\`, which serializers must escape normally — the floor isn't a - // license to skip escaping. (Length: the 31-char cap matches deviceModel_'s 32-byte - // buffer; over-long is rejected, not truncated.) Declaring the rule on the control - // keeps it with the data, so it holds for every transport that writes deviceModel. + /// Per-control validator for `deviceModel`, applied on EVERY write path (HTTP + /// /api/control, APPLY_OP over serial, persistence load) via ControlDescriptor::validate. + /// Accepts 1..31 chars, ASCII-printable (0x20–0x7E), no embedded NUL. The printable floor + /// rejects control bytes / NULs that would corrupt downstream consumers — JSON + /// serialization (control bytes need \u escaping at best, break naive emitters at worst), + /// the device UI (rendered verbatim; a BEL/ESC would mangle the page), and C-string + /// handling (no embedded NUL → strlen/strcpy round-trip cleanly). Printable ASCII still + /// contains `"` and `\`, which serializers must escape normally — the floor isn't a + /// license to skip escaping. (Length: the 31-char cap matches deviceModel_'s 32-byte + /// buffer; over-long is rejected, not truncated.) Declaring the rule on the control + /// keeps it with the data, so it holds for every transport that writes deviceModel. static bool validateDeviceModel(const char* value) { if (!value) return false; size_t n = std::strlen(value); diff --git a/src/light/drivers/Drivers.h b/src/light/drivers/Drivers.h index 14ac5b7e..736aa92b 100644 --- a/src/light/drivers/Drivers.h +++ b/src/light/drivers/Drivers.h @@ -13,86 +13,96 @@ namespace mm { +/// Base class for one driver — a consumer that reads the shared source buffer and +/// emits it to a destination (a physical LED output, a network sink, or the preview). +/// +/// A driver optionally reads dimensions from an active Layer, optionally applies the +/// shared output correction, and optionally restricts its output to a contiguous +/// *window* of the source buffer (see `addWindowControls`). It plays the same +/// zero-state role for drivers that EffectBase does for effects. class DriverBase : public MoonModule { public: ModuleRole role() const override { return ModuleRole::Driver; } virtual void setSourceBuffer(Buffer* buf) = 0; - // Optional: drivers that need dimensions (e.g. PreviewDriver describing the LED grid in - // the WebSocket frame) call layer_ for current physical width/height/depth. ArtNet doesn't - // need it — it just streams bytes. - // - // This is the *active* layer for dimension queries, not a 1:1 wiring - // constraint: each driver outputs to a single physical fixture, and the - // Drivers container hands it one Layer for dimensions regardless of how many - // layers feed the output buffer. + /// Set the active Layer this driver reads dimensions from. Optional: drivers that + /// need dimensions (such as PreviewDriver describing the LED grid in the WebSocket + /// frame) call `layer_` for current physical width/height/depth; ArtNet doesn't need + /// it — it just streams bytes. + /// + /// This is the *active* layer for dimension queries, not a 1:1 wiring constraint: + /// each driver outputs to a single physical fixture, and the Drivers container hands + /// it one Layer for dimensions regardless of how many layers feed the output buffer. void setLayer(Layer* layer) { layer_ = layer; } - // The configured window (start light, count; count 0 = to end of buffer). - // Public for tests pinning the slice arithmetic; production reads via - // windowSlice(). See start_/count_ below. + /// First light of the configured window. Public for tests pinning the slice + /// arithmetic; production reads via `windowSlice()`. See `start_`/`count_` below. uint16_t windowStart() const { return start_; } + /// Number of lights in the configured window (0 = to end of buffer). uint16_t windowCount() const { return count_; } - // Set the window directly (the UI sets it via the start/count controls; this - // is for code-wiring a driver's slice and for tests). Takes effect on the next - // config parse / loop, like a control edit. + /// Set the window directly (the UI sets it via the start/count controls; this is + /// for code-wiring a driver's slice and for tests). Takes effect on the next config + /// parse / loop, like a control edit. void setWindow(uint16_t start, uint16_t count) { start_ = start; count_ = count; } - // The active Layer this driver reads dimensions from — null when no Layer is - // wired (e.g. the last Layer was deleted). Drivers must tolerate null here. + /// The active Layer this driver reads dimensions from — null when no Layer is wired + /// (such as after the last Layer was deleted). Drivers must tolerate null here. Layer* layer() const { return layer_; } - // Shared output correction (brightness LUT + channel order + white) owned by the - // Drivers container. Default no-op so Preview (which shows the raw logical buffer) - // and any future preview-style driver opt out for free; only physical drivers - // (ArtNet, future LED) override to apply it. + /// Hand this driver the shared output correction (brightness LUT + channel order + + /// white) owned by the Drivers container. Default no-op so Preview (which shows the + /// raw logical buffer) and any preview-style driver opt out for free; only physical + /// drivers (ArtNet, LED) override to apply it. virtual void setCorrection(const Correction* /*c*/) {} - // Notified by Drivers when the shared Correction's outChannels may have changed - // (a lightPreset switch RGB↔RGBW). Default no-op; physical drivers that own an - // intermediate correction-applied buffer override to resize it OFF the hot path. - // Topology changes (light count, channels per light) already flow through - // onBuildState — this hook is just for the preset-driven channel-count change - // that doesn't trigger a structural rebuild. + /// Notified by Drivers when the shared Correction's outChannels may have changed (a + /// lightPreset switch RGB↔RGBW). Default no-op; physical drivers that own an + /// intermediate correction-applied buffer override to resize it OFF the hot path. + /// Topology changes (light count, channels per light) already flow through + /// onBuildState — this hook is just for the preset-driven channel-count change that + /// doesn't trigger a structural rebuild. virtual void onCorrectionChanged() {} - // Clear 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();`. + /// 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 source-buffer window (start, count) --------------------------- - // Every driver reads the SAME shared source buffer (Drivers hands the one - // Buffer* to each child) and outputs a contiguous slice of it: lights - // [start_, start_+count_). This is how light distribution is made *explicit* - // and order-independent — a second driver on a different slice (e.g. an - // onboard status LED at index 0, the main strip from index 1) just sets its - // own window, rather than the buffer being split by driver order. `count_`==0 - // means "the rest of the buffer from start_" (the common whole-buffer case). - // NetworkSendDriver's universe maps onto the same window; the LED drivers' - // pins/ledsPerPin distribute lights *within* the window. - uint16_t start_ = 0; - uint16_t count_ = 0; // 0 = to end of buffer + /// Every driver reads the SAME shared source buffer (Drivers hands the one + /// `Buffer*` to each child) and outputs a contiguous slice of it: lights + /// `[start_, start_+count_)`. This is how light distribution is made *explicit* + /// and order-independent — a second driver on a different slice (such as an + /// onboard status LED at index 0, the main strip from index 1) just sets its + /// own window, rather than the buffer being split by driver order. `count_`==0 + /// means "the rest of the buffer from start_" (the common whole-buffer case). + /// NetworkSendDriver's universe maps onto the same window; the LED drivers' + /// pins/ledsPerPin distribute lights *within* the window. It is the alternative + /// to a "split the buffer by sibling order" model some controllers use — here the + /// user (or catalog) says which slice goes where. + uint16_t start_ = 0; ///< First source-buffer light this driver outputs (default 0). + uint16_t count_ = 0; ///< Lights to output from `start_`; 0 = to end of buffer. - // Add the two window controls — call from a driver's onBuildControls(). Kept - // a helper (not auto-added) so a driver opts in by calling it where its other - // controls go, keeping control *order* in the driver's hands. + /// Add the two window controls — call from a driver's onBuildControls(). Kept a + /// helper (not auto-added) so a driver opts in by calling it where its other + /// controls go, keeping control *order* in the driver's hands. void addWindowControls() { controls_.addUint16("start", start_); controls_.addUint16("count", count_); } - // True if `name` is one of the window controls — a driver folds this into its - // controlChangeTriggersBuildState() so editing the slice re-runs its config. + /// True if `name` is one of the window controls — a driver folds this into its + /// controlChangeTriggersBuildState() so editing the slice re-runs its config. static bool isWindowControl(const char* name) { return std::strcmp(name, "start") == 0 || std::strcmp(name, "count") == 0; } - // Resolve the window against a buffer of `bufN` lights: writes the clamped - // first light to `outStart` and the slice length to `outLen` (0 if the window - // starts past the end). The textbook [start, start+count) clamp — every - // driver calls this instead of reading from light 0. + /// Resolve the window against a buffer of `bufN` lights: writes the clamped first + /// light to `outStart` and the slice length to `outLen` (0 if the window starts + /// past the end — the driver then idles, no out-of-bounds read). The textbook + /// `[start, start+count)` clamp — every driver calls this instead of reading from + /// light 0. void windowSlice(nrOfLightsType bufN, nrOfLightsType& outStart, nrOfLightsType& outLen) const { outStart = start_ < bufN ? start_ : bufN; @@ -145,22 +155,88 @@ class DriverBase : public MoonModule { } }; +/// Top-level container for one or more drivers — the consumer side of the pipeline. +/// Owns the shared output buffer (when memory allows) and performs blend+map from +/// every layer's buffer into it each frame. +/// +/// **Naming convention.** Capital `Drivers` is the container class; lowercase +/// "driver"/"drivers" is the English singular/plural for individual `DriverBase` +/// children. Capitalisation disambiguates "the Drivers container" from "two drivers +/// running" (same rule for `Layouts`/layout and `Layers`/layer). +/// +/// **Shared output buffer.** Necessary because blend+map writes to arbitrary physical +/// positions via LUT — the output is not filled sequentially, so a driver cannot read +/// chunk-by-chunk until the full buffer is populated. It uses the same `Buffer` type a +/// Layer does, sized by the Layouts container. Exception: when exactly one layer is +/// enabled AND its mapping is 1:1 unshuffled (no LUT — grid layout, no serpentine), +/// Drivers skips its own buffer and lets drivers read directly from the layer's buffer +/// (the zero-copy fast path, at the cost of parallelism). +/// +/// **Multi-layer composition.** When two or more layers are enabled, Drivers composites +/// them into the shared output buffer each frame in Layers container order (bottom→top, +/// via `forEachEnabledLayer`). The bottom layer clears and overwrites the buffer; each +/// layer above blends onto the accumulated frame per its own `blendMode` and `opacity` +/// (the inert per-Layer controls). Drivers owns the orchestration because only it sees +/// the stack order and the output buffer; the layers carry only the parameters. The +/// per-pixel blend math lives in `blendMap` (integer-only, per the hot-path rule). A +/// full-opacity overwrite/additive layer pays no alpha arithmetic, so per-frame cost +/// scales with the enabled-layer count. With a single enabled layer there is no +/// composite: the fast path applies (no-LUT → zero-copy; with a LUT → one blend+map pass +/// into the output buffer). +/// +/// **Output correction.** Drivers owns the shared output-correction state (a +/// `Correction`: brightness LUT, channel-order table, output channel count, derive-white +/// flag) and exposes `brightness`, `lightPreset`, and the global `palette` controls; each +/// *physical* driver child applies the correction per-light as it reads the source buffer, +/// while Preview ignores it (shows the raw logical buffer). `onUpdate` rebuilds the +/// correction on a `brightness`/`lightPreset` change and hands each child a +/// `const Correction*`. Every driver sees the same composited output. Palette model + +/// names follow FastLED's, credited as prior art; implementation in `src/light/Palette.h`. +/// +/// **Per-driver source window (`start` / `count`).** A window-aware output driver reads +/// the shared source buffer and outputs a contiguous slice of it — its *window* — making +/// light distribution explicit and order-independent: each driver names its own slice, so +/// reordering drivers does not change which lights each outputs (only tick order). The +/// motivating case: an onboard status LED with window `[0, 1)` and a main strip with +/// window `[1, …)` as two driver instances on the same buffer, neither stealing the +/// other's lights. `DriverBase::addWindowControls()` opts a driver in (see there); a driver +/// that outputs the whole buffer (such as PreviewDriver) simply doesn't call it. +/// +/// **Prior art:** MoonLight's PhysicalLayer — owns `channelsD` (display buffer), +/// `compositeLayers()` maps virtualChannels → channelsD, parallelism via a semaphore +/// (driver signals completion, compositor writes) +/// (https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/PhysicalLayer.h). class Drivers : public MoonModule { public: const char* acceptsChildRoles() const override { return "driver"; } - // 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. + /// Global brightness (0–255). 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. + /// + /// Default low (≈8%). A fresh device with LEDs wired but no power budget set + /// (such as 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 - uint8_t palette = 0; // index into mm::palettes::kBuiltins; the global active palette effects read + /// Physical wire format: channel order and whether the light is RGBW (index into + /// `kLightPresetOptions`; 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. + /// + /// GRB (index 2) is 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 + /// The global active colour palette (index into `mm::palettes::kBuiltins`; + /// `Rainbow`, `Party`, `Lava`, `Ocean`, …). Palette-driven effects read it via + /// `Palettes::active()` and colour their pixels through `colorFromPalette(index)`, so + /// changing this recolours every such effect live. The select index expands the chosen + /// gradient into the active 16-entry palette on `onUpdate` (cheap, off the hot path). + uint8_t palette = 0; // Two ways to wire the source Layer: // - setLayers(Layers*): bind the container; layer_ is re-resolved from diff --git a/src/light/drivers/PreviewDriver.h b/src/light/drivers/PreviewDriver.h index 785773ee..0b44be61 100644 --- a/src/light/drivers/PreviewDriver.h +++ b/src/light/drivers/PreviewDriver.h @@ -9,56 +9,68 @@ namespace mm { -// Streams a true-shape 3D preview to the web UI over the binary WebSocket. -// -// The preview is a POINT LIST, not a dense grid: only the real lights are sent, -// at their real (x,y,z) positions. This is the proven MoonLight model (virtual -// grid → physical sparse lights; positions sent once at mapping time, channels -// per frame). Two message types — PreviewDriver owns both wire formats; the -// HTTP server is a domain-neutral BinaryBroadcaster that just writes the bytes: -// -// 0x03 coordinate table (sent when the geometry changes — every LUT/layout rebuild -// via onBuildState — and when a new client connects, so a refresh gets it; never -// per-frame): -// [0x03][count:u32][bx:u8][by:u8][bz:u8][stride:u16][(x,y,z):u8×3 × count] -// bx/by/bz = bounding-box extent (for client centring); positions are -// 1 byte/axis (a layout box ≤255/axis is the realistic case). count is u32 so a -// >65535-light panel (big ArtNet/HUB75 walls) isn't capped by the wire format — -// it matches nrOfLightsType (u32 on PSRAM boards). -// -// 0x02 per-frame channels: [0x02][count:u32][stride:u16][(r,g,b) × count] -// RGB by driver index, every `stride`-th light. The browser positions -// triple i at coord-table entry i*stride. -// -// `count` here is the number of points actually sent = ceil(lightCount/stride). -// stride>1 only when lightCount*3 would exceed the send-buffer cap (a large -// dense grid); sparse layouts (sphere) send every light exactly (stride 1). +/// Streams a true-shape 3D preview to the web UI over the binary WebSocket. +/// +/// The preview is a POINT LIST, not a dense grid: only the real lights are sent, +/// at their real (x,y,z) positions. This is the proven MoonLight model (virtual +/// grid → physical sparse lights; positions sent once at mapping time, channels +/// per frame). Two message types — PreviewDriver owns both wire formats; the +/// HTTP server is a domain-neutral BinaryBroadcaster that just writes the bytes: +/// +// --8<-- [start:wire-format] +/// 0x03 coordinate table (sent when the geometry changes — every LUT/layout rebuild +/// via onBuildState — and when a new client connects, so a refresh gets it; never +/// per-frame): +/// [0x03][count:u32][bx:u8][by:u8][bz:u8][stride:u16][(x,y,z):u8×3 × count] +/// bx/by/bz = bounding-box extent (for client centring); positions are +/// 1 byte/axis (a layout box ≤255/axis is the realistic case). count is u32 so a +/// >65535-light panel (big ArtNet/HUB75 walls) isn't capped by the wire format — +/// it matches nrOfLightsType (u32 on PSRAM boards). +/// +/// 0x02 per-frame channels: [0x02][count:u32][stride:u16][(r,g,b) × count] +/// RGB by driver index, every `stride`-th light. The browser positions +/// triple i at coord-table entry i*stride. +// --8<-- [end:wire-format] +/// +/// `count` is the number of points actually kept after lattice downsampling (the +/// lights whose position satisfies `pos ≡ 0 mod stride`) — a client sizes its buffer +/// from this `count`, not from the light total. `stride` rises above 1 only when the +/// point set would exceed the runtime send-buffer cap (`min(display, memory)`); below +/// the cap every light is sent (stride 1), so a sparse layout streams in full. class PreviewDriver : public DriverBase { public: - // The 3D preview the web UI renders streams from this driver. Deleting or - // replacing it from the UI would silently kill that preview, so it opts out - // of user-editing — it stays a fixed child of Drivers. + /// The 3D preview the web UI renders streams from this driver. Deleting or + /// replacing it from the UI would silently kill that preview, so it opts out + /// of user-editing — it stays a fixed child of Drivers. bool userEditable() const override { return false; } - // Preview stream rate (Hz), independent of render FPS. User-tunable 1-60. + /// Preview stream rate (Hz), independent of render FPS. User-tunable 1-60. This + /// is a *ceiling*: the effective rate self-limits to what the link sustains. uint8_t fps = 24; - // The sink each message is pushed to (HttpServerModule, as a - // BinaryBroadcaster). Wired in main.cpp. Light depends only on the - // interface, not the concrete HTTP server. + /// Set the sink each message is pushed to (HttpServerModule, as a + /// BinaryBroadcaster). Wired in main.cpp. Light depends only on the + /// interface, not the concrete HTTP server. void setBroadcaster(BinaryBroadcaster* b) { broadcaster_ = b; } + /// Bind the one control, `fps` (1-60). void onBuildControls() override { controls_.addUint8("fps", fps, 1, 60); } + /// Point the driver at the sparse driver buffer the LED/ArtNet drivers also read + /// (the MappingLUT fills it with exactly the real lights). The driver streams + /// straight from it — no preview-side copy. void setSourceBuffer(Buffer* buf) override { sourceBuffer_ = buf; } - // A rebuild (layout add/replace/remove, resize, modifier change) ran — the - // light set / positions may have changed, so rebuild + broadcast the - // coordinate table. This is the MoonLight "positions once at mapping time". + /// A rebuild (layout add/replace/remove, resize, modifier change) ran — the + /// light set / positions may have changed, so rebuild + broadcast the coordinate + /// table (the MoonLight "positions once at mapping time"). Cancels any in-flight + /// resumable colour send *first*: a resize frees+reallocs the producer buffer, so + /// a half-sent frame would read freed memory — a use-after-free guard pinned by a + /// test. This coupling spans PreviewDriver ↔ HttpServerModule ↔ the Layer buffer. void onBuildState() override { // A resize frees+reallocs the producer buffer, so any in-flight resumable colour send holds // a pointer that's about to dangle — cancel it BEFORE the rebuild (the browser discards the @@ -68,6 +80,11 @@ class PreviewDriver : public DriverBase { MoonModule::onBuildState(); } + /// Per-tick: (re)stream the coordinate table when the geometry or client set + /// changed, then stream one colour frame if the previous one finished draining. + /// The frame rate self-limits to what the link sustains (sheds rate first, then + /// spatial resolution via adaptive downscale), so a large grid never stalls the + /// loop or tears — it always delivers a complete frame. void loop() override { if (fps == 0) return; uint32_t now = platform::millis(); @@ -139,8 +156,11 @@ class PreviewDriver : public DriverBase { } } - // Build (or rebuild) the cached coordinate table from the layout's real - // lights and broadcast it. Public so tests can drive it deterministically. + /// Build (or rebuild) the cached coordinate table from the layout's real lights + /// and broadcast it (the `0x03` message). Above the point cap — `min(display, + /// memory)`, memory from `maxAllocBlock()` — lights are kept on a spatial lattice + /// (position ≡ 0 mod stride), sampling positions not indices so there is no moiré. + /// Public so tests can drive it deterministically. void buildAndSendCoordTable() { coordCount_ = 0; if (!layer_ || !layer_->layouts()) return; @@ -257,9 +277,9 @@ class PreviewDriver : public DriverBase { coordPending_ = !broadcaster_->endBinaryFrame(); } - // STREAM one per-frame 0x02 RGB message from the producer buffer — no intermediate buffer. - // Returns whether every client got it (false → loop() drives adaptive downscaling). Public - // so tests can drive it without the loop() rate-limit. + /// Stream one per-frame `0x02` RGB message straight from the producer buffer — no + /// intermediate copy. Returns whether every client got it (false → loop() drives + /// adaptive downscaling). Public so tests can drive it without loop()'s rate-limit. bool sendFrame() { if (!broadcaster_ || !sourceBuffer_ || !sourceBuffer_->data() || coordCount_ == 0) return false; const uint8_t* src = sourceBuffer_->data(); diff --git a/src/light/effects/EffectBase.h b/src/light/effects/EffectBase.h index 1466cbbd..68a92ae7 100644 --- a/src/light/effects/EffectBase.h +++ b/src/light/effects/EffectBase.h @@ -16,46 +16,73 @@ class Layer; // forward declaration // ModuleFactory::registerType<T> captures dim from a probe via if-constexpr — // no per-domain registration wrapper is needed. +/// Light-domain MoonModule subclass for effects — adds the rendering context. +/// +/// **Design.** A zero-state convenience layer: it holds no data of its own, just +/// accessors (`buffer()`, `width()`, `height()`, `depth()`, `elapsed()`, …) that +/// forward to the parent Layer. An effect reads its rendering context through these +/// instead of caching a `Layer*` and the dimensions itself. `DriverBase` plays the +/// same role for drivers against the Drivers container. +/// +/// **Animation guidelines.** Effects use BPM (beats per minute) for speed controls, +/// not abstract 0–255 ranges, giving users a musical reference (60 BPM = one beat per +/// second, 120 BPM = two; default 60). Animation must be resolution-independent: +/// multiply the time offset by the panel dimension (width or height) so the perceived +/// visual speed is the same regardless of display size — otherwise large displays look +/// sluggish and small ones frantic at the same BPM. Animation is driven by elapsed +/// millis (via `elapsed()`), not frame count, so speed stays consistent regardless of +/// FPS; the speed slider controls the animation dynamics, never the framerate, which +/// should always be maximal for smooth motion. +/// +/// **Prior art:** MoonLight's Node + VirtualLayer — effects access +/// `layer->width()/height()/depth()` directly via the VirtualLayer pointer (no separate +/// EffectBase), buffer access via `layer->virtualChannels`, time via `timeMicros()` +/// (https://github.com/ewowi/MoonLight/blob/main/src/MoonBase/Nodes.h, +/// https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/VirtualLayer.h). class EffectBase : public MoonModule { public: ModuleRole role() const override { return ModuleRole::Effect; } - // Default D3 means "I iterate every axis the layer gives me" — the framework - // doesn't extrude on your behalf. Override to D2 if you write only the z=0 - // slice (Layer::extrude duplicates it across z); to D1 if you write only the - // x=0,z=0 column (1D runs along Y — extrude fills x and z). Declaring D2/D1 is an opt-in promise: - // the framework treats slices you don't write as authoritative copies of the - // ones you did. On a 2D layer (depth=1) the D3-vs-D2 distinction is free — - // extrude's z-fill loop is guarded by `depth_ > 1` and does nothing. - // - // **Contract — loop() must honour layer dimensions.** dimensions() is a - // claim about which axes the effect *iterates*, not a guarantee that the - // layer has that many axes. A D3 effect may run on a D1 or D2 layer (the - // layer just has depth=1 and/or height=1). Your loop must read width(), - // height(), and depth() at frame time and iterate exactly those bounds — - // never hardcode a maximum, never assume your declared D matches the - // layer's. Concretely: - // - A D3 effect on a 1D layer (h=d=1) iterates only x; y and z stay 0. - // - A D2 effect on a 1D layer (h=d=1) iterates only x; y stays 0. - // - A D1 effect on a 3D layer writes its (x=0) column and extrude fills the rest. - // Hardcoding a fixed `z < SOMETHING` is a buffer-overrun bug — the buffer - // is sized to width × height × depth × channels, no more. Tests in - // test_extrude.cpp pin the D3-on-2D and D3-on-1D paths for the shipped - // effects; add similar pinning for new D3 effects with z-aware math. + /// Which axes the effect *iterates* — a claim, not a guarantee about the layer. + /// + /// Default D3 means "I iterate every axis the layer gives me" — the framework + /// doesn't extrude on your behalf. Override to D2 if you write only the z=0 + /// slice (Layer::extrude duplicates it across z); to D1 if you write only the + /// x=0,z=0 column (1D runs along Y — extrude fills x and z). Declaring D2/D1 is an opt-in promise: + /// the framework treats slices you don't write as authoritative copies of the + /// ones you did. On a 2D layer (depth=1) the D3-vs-D2 distinction is free — + /// extrude's z-fill loop is guarded by `depth_ > 1` and does nothing. The `dim` + /// int (1/2/3) is emitted in `/api/types`; the UI derives the 📏/🟦/🧊 chip from + /// it, so it isn't repeated in each module's `tags()`. + /// + /// **Contract — loop() must honour layer dimensions.** `dimensions()` is a + /// claim about which axes the effect *iterates*, not a guarantee that the + /// layer has that many axes. A D3 effect may run on a D1 or D2 layer (the + /// layer just has depth=1 and/or height=1). Your loop must read `width()`, + /// `height()`, and `depth()` at frame time and iterate exactly those bounds — + /// never hardcode a maximum, never assume your declared D matches the + /// layer's. Concretely: + /// - A D3 effect on a 1D layer (h=d=1) iterates only x; y and z stay 0. + /// - A D2 effect on a 1D layer (h=d=1) iterates only x; y stays 0. + /// - A D1 effect on a 3D layer writes its (x=0) column and extrude fills the rest. + /// Hardcoding a fixed `z < SOMETHING` is a buffer-overrun bug — the buffer + /// is sized to width × height × depth × channels, no more. Tests in + /// test_extrude.cpp pin the D3-on-2D and D3-on-1D paths for the shipped + /// effects; add similar pinning for new D3 effects with z-aware math. virtual Dim dimensions() const { return Dim::D3; } - // Parent is always a Layer (defined in Layer.h after Layer is complete) + /// Parent is always a Layer (defined in Layer.h after Layer is complete). Layer* layer() const; - // Convenience accessors — delegate to parent Layer - // Defined after Layer is complete (in Layer.h) + // Convenience accessors — delegate to parent Layer. + // Defined after Layer is complete (in Layer.h). uint8_t* buffer(); lengthType width() const; lengthType height() const; lengthType depth() const; uint8_t channelsPerLight() const; nrOfLightsType nrOfLights() const; - uint32_t elapsed() const; + uint32_t elapsed() const; ///< Milliseconds since render start — drive animation off this, not frame count. }; } // namespace mm diff --git a/src/light/effects/FireEffect.h b/src/light/effects/FireEffect.h index 3d0cbaf4..fb04ee2f 100644 --- a/src/light/effects/FireEffect.h +++ b/src/light/effects/FireEffect.h @@ -10,6 +10,11 @@ namespace mm { +/// Fire2012-style heat field: sparks at the base rise and cool through the active +/// palette (heat = palette index, cold at the low end, hottest at the high end); +/// spark count scales with width. The flame colour comes from the active palette — +/// the Lava palette (black->red->orange->yellow->white) gives the classic look; any +/// palette works (Ocean/Forest turn the flame blue/green). // Author: Mark Kriegsman's Fire2012 (FastLED); MoonLight adapts MatrixFireFast by toggledbits — https://github.com/toggledbits/MatrixFireFast class FireEffect : public EffectBase { public: diff --git a/src/light/effects/TextEffect.h b/src/light/effects/TextEffect.h index 33fcf44b..6e04b08c 100644 --- a/src/light/effects/TextEffect.h +++ b/src/light/effects/TextEffect.h @@ -35,7 +35,7 @@ class TextEffect : public EffectBase { bool scroll = false; // false = static top-left; true = horizontal marquee uint8_t font = 1; // index into fonts::kAll (0 = 4x6, 1 = 6x8) uint8_t speed = 30; // marquee speed (pixels/sec-ish); only used when scrolling - uint8_t hue = 0; // palette index for the text colour + uint8_t hue = 128; // palette index for the text colour (mid-palette; 0 is black in some palettes) void onBuildControls() override { controls_.addTextArea("text", text_, sizeof(text_)); diff --git a/src/light/layers/BlendMap.h b/src/light/layers/BlendMap.h index 0dee148a..856744b2 100644 --- a/src/light/layers/BlendMap.h +++ b/src/light/layers/BlendMap.h @@ -7,26 +7,50 @@ namespace mm { -// How a layer's pixels combine into the destination during composition. -// Overwrite — dst = src (replace; the first/bottom layer, fastest) -// Alpha — dst = src*opacity + dst*(255-opacity) (opacity-weighted over) -// Additive — dst = clamp(dst + src*opacity/255) (adds light, never dims) -enum class BlendOp : uint8_t { Overwrite, Alpha, Additive }; +/// How a layer's pixels combine into the destination during composition. +/// +/// The per-Layer `blendMode` / `opacity` controls that select the op live on the +/// Layer; the Drivers container reads them together with the layer stack order and +/// drives one `blendMap` pass per enabled layer each frame. +enum class BlendOp : uint8_t { + Overwrite, ///< `dst = src` (replace; the first/bottom layer, fastest — no read-back) + Alpha, ///< `dst = src*opacity + dst*(255-opacity)` (opacity-weighted over) + Additive, ///< `dst = clamp(dst + src*opacity/255)` (adds light, never dims) +}; -// Fast 8-bit "divide by 255": exact for 0..65535. Avoids a real divide on the -// hot path (the textbook (x + (x>>8) + 1) >> 8 trick). +/// Fast 8-bit "divide by 255": exact for 0..65535. Avoids a real divide on the +/// hot path (the textbook `(x + (x>>8) + 1) >> 8` trick). inline uint8_t div255(uint16_t x) { return static_cast<uint8_t>((x + (x >> 8) + 1) >> 8); } -// Reads from logical buffer (src), writes/blends to physical buffer (dst) via LUT. -// -// `op` + `opacity` decide how each light combines into dst; `clearFirst` clears -// dst before writing (the first/bottom layer in a composite — so physical cells -// with no source stay black; subsequent layers blend ONTO the accumulated frame). -// For a single layer the caller passes op=Overwrite, opacity=255, clearFirst=true, -// which takes the exact fast path this had before composition (memcpy / plain copy). -// -// The op/opacity branch is resolved ONCE here, before the per-light loop, so each -// path is a tight specialized loop with no per-pixel mode check (hot-path rule). +/// Reads a layer's logical buffer (`src`) and writes the mapped result into a +/// physical destination buffer (`dst`) via the layer's LUT — called by the Drivers +/// container once per enabled layer each frame. +/// +/// `op` + `opacity` decide how each light combines into `dst`; `clearFirst` clears +/// `dst` before writing (the first/bottom layer in a composite — so physical cells +/// with no source, such as a sparse layout's lattice gaps, stay black; subsequent +/// layers pass `false` to blend ONTO the accumulated frame below). For a single +/// layer the caller passes `op=Overwrite`, `opacity=255`, `clearFirst=true`, which +/// takes the exact fast path this had before composition (memcpy / plain copy). +/// +/// **Integer-only combine math** (the hot-path per-light rule), one tight +/// specialised loop chosen ONCE before the per-light loop — no per-pixel mode check: +/// +/// - **Overwrite** (default / bottom layer): plain copy, no read-back. A dense grid +/// (no LUT) is a `memcpy`; a single-write LUT (mirror, shuffle, sparse box→driver) +/// copies source→destination per mapped light. A non-overwriting LUT (one that +/// folds several logical lights onto one physical cell) routes through the additive +/// accumulate path so overlaps sum-with-clamp rather than last-writer-win. +/// - **Additive**: `dst = clamp(dst + src·opacity)` — sum with saturation at 255, +/// opacity scaling the source. +/// - **Alpha** (over): `dst = (src·α + dst·(255−α)) / 255` — the textbook 8-bit +/// alpha-over, division by 255 via the fast `div255` reciprocal. Full opacity (255) +/// collapses to a plain overwrite (no blend cost). +/// +/// A dense-grid layer has no LUT, so its buffer blends 1:1 (source index == physical +/// index, no lookup); a layer with a LUT maps each logical light to its physical +/// destination(s) first. Physical indices come from the LUT, built in-range from the +/// shared Layouts, so they address `dst` in bounds by construction. inline void blendMap(const Buffer& src, Buffer& dst, const MappingLUT& lut, uint8_t channelsPerLight, BlendOp op = BlendOp::Overwrite, uint8_t opacity = 255, diff --git a/src/light/layers/Buffer.h b/src/light/layers/Buffer.h index b01a2b39..7f923e11 100644 --- a/src/light/layers/Buffer.h +++ b/src/light/layers/Buffer.h @@ -9,6 +9,23 @@ namespace mm { +/// Contiguous light-data buffer, shared between the layers that write it (effects) +/// and the driver groups that read it. When memory allows, layers and driver groups +/// each own a buffer (so they run in parallel); when memory is tight, one buffer is +/// shared. +/// +/// **Storage:** a raw `uint8_t*` (not `RGB*`), so any channel layout fits — RGB, +/// RGBW, or multi-channel DMX fixtures — addressed by channel count + offset. +/// Allocated once via `platform::alloc` (PSRAM when available) outside the hot path +/// and reused every frame; a `std::span<uint8_t>` view is the zero-cost safe accessor. +/// +/// **Locking:** a semaphore costs ~150 bytes on ESP32, so prefer lock-free patterns — +/// an atomic pointer swap for double-buffering, a single-slot SPSC handoff, or one +/// shared semaphore across layers rather than one per layer. +/// +/// **Prior art:** MoonLight's `VirtualLayer.virtualChannels` — a raw `uint8_t*` sized +/// by `channelsPerLight * nrOfLights`, RGB/RGBW/DMX via LightsHeader offsets +/// (https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/VirtualLayer.h). class Buffer { public: Buffer() = default; diff --git a/src/light/layers/Layer.h b/src/light/layers/Layer.h index 5a023c4a..bc919203 100644 --- a/src/light/layers/Layer.h +++ b/src/light/layers/Layer.h @@ -14,6 +14,23 @@ namespace mm { +/// A `Layer` MoonModule (role `ModuleRole::Layer`, child of the `Layers` container) owns a buffer, a mapping LUT, an ordered effect list, and an ordered modifier list, and references the shared `Layouts` that describes the physical topology. +/// +/// **Ownership:** a `Buffer` (logical light data, sized to the logical box); a `MappingLUT` (logical lights to physical positions); effects (write lights into the buffer, dynamic heap-grown list, no fixed max); modifiers (transform the LUT or light values, same dynamic list). +/// +/// **Composition:** two controls, `blendMode` and `opacity`, govern how this Layer composites onto the layers below it. They are inert on the Layer — it never reads them; a Layer can't know its position in the stack or what's beneath it. The `Drivers` container reads each enabled Layer's two values plus the container child order and does the compositing (bottom layer overwrites, each layer above blends per its mode and opacity). The value lives here so it travels with the Layer through add / delete / reorder — no separate sync-prone blend list on Drivers. The blend math itself lives in `BlendMap`. +/// +/// **Buffer persistence:** the buffer persists frame-to-frame — the Layer does NOT clear it. This is the FastLED / WLED / MoonLight convention: the buffer holds the previous frame so an effect can fade it for trails (`fadeToBlackBy`) or read prior pixels (a scroll, Game-of-Life). Each effect owns its background. `rebuildLUT` clears once on the cold path so a freshly added effect starts black. +/// +/// **rebuildLUT (cold path):** called when a layout or modifier control changes. Reads physical dimensions from `Layouts`, folds the box through each enabled static modifier to compute the logical box, allocates the buffer and LUT, and for the common case (no modifier, dense grid in natural order) skips the table entirely with an identity mapping. The general path folds each physical light through the static chain to its logical cell via a textbook counting-sort CSR build. +/// +/// **render (hot path):** runs each enabled effect in order (all write the same buffer), calls `extrude` after each effect to duplicate its written slice across the axes it doesn't iterate, then ticks each enabled modifier. A live (animated) modifier triggers the per-frame `applyLivePass` backward gather; a beat-driven one asks for a single coalesced rebuild. +/// +/// **extrude:** lets a low-dimensional effect work on a higher-dimensional layer without per-effect changes — a D2 effect on a 3D layer has its z=0 slice copied across z, a D1 effect its x=0 column copied across x, then that row across the rest. Cost is zero for D3 effects (the default, an early return) and zero when the layer's unused axes are size 1 (a D2 effect on a 2D layer). Real `memcpy` work only happens when the layer has more dimensions than the effect writes. +/// +/// **Status:** the status slot shows the LOGICAL box the effects render into (`` `<w>×<h>×<d>` ``), which can differ from the physical box shown on `Layouts` (a Mirror-XY modifier folds a 128×128 physical layout into a 64×64 logical box). The same slot carries memory-degradation warnings when a build can't fit (`modifier mapping skipped`, `buffer reduced`, `buffer allocation failed`, all `— not enough memory`), and a warning wins over the neutral box line. +/// +/// **Prior art:** MoonLight's `VirtualLayer` — `oneToOneMapping` fast-path flag, `virtualChannels` per-layer buffer, `effectDimension`, a `nodes` vector for effects/modifiers, and `forEachLight` per-logical-light iteration that asks the modifier for physical destinations (https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/VirtualLayer.h). class Layer : public MoonModule { public: ModuleRole role() const override { return ModuleRole::Layer; } @@ -41,9 +58,9 @@ class Layer : public MoonModule { MoonModule::onBuildControls(); } - // How this Layer composites when stacked above another (read by Drivers). - // Maps the blendMode select index to the BlendMap op. Index order must match - // kBlendModeOptions above. + /// How this Layer composites when stacked above another (read by Drivers). + /// Maps the blendMode select index to the BlendMap op. Index order must match + /// kBlendModeOptions above. BlendOp blendOp() const { return blendMode == 1 ? BlendOp::Additive : BlendOp::Alpha; } @@ -230,14 +247,15 @@ class Layer : public MoonModule { } } - // Copy the effect's written slice to fill the unused axes. Called after each - // effect's loop(). Buffer layout is (z * h + y) * w + x channels per light. - // - // Hot-path shape: D3 effects (the default) take the early return and pay - // nothing beyond one comparison and a branch. On a 2D layout (depth=1) the - // z-fill is naturally a no-op regardless of effectDim — the `depth_ > 1` - // guard short-circuits. Same for D1 on a 1D layout. Real work only happens - // when the effect declared fewer axes than the layout has. + /// Copy the effect's written slice to fill the unused axes. Called after each + /// effect's loop(). Buffer layout is (z * h + y) * w + x channels per light. + /// + /// Hot-path shape: D3 effects (the default) take the early return and pay + /// nothing beyond one comparison and a branch. On a 2D layout (depth=1) the + /// z-fill is naturally a no-op regardless of effectDim — the `` `depth_ > 1` `` + /// guard short-circuits. Same for D1 on a 1D layout. Real work only happens + /// when the effect declared fewer axes than the layout has. See + /// EffectBase § Dimensions and auto-extrusion for the effect-side contract. void extrude(Dim effectDim) { if (effectDim == Dim::D3) return; uint8_t* buf = buffer_.data(); @@ -299,7 +317,13 @@ class Layer : public MoonModule { bool lutSkipped() const { return lutSkipped_; } - // Precondition: physicalWidth_/Height_/Depth_ must be set (call from onBuildState) + /// Cold path, called from onBuildState after physical dimensions are known. + /// Applies each enabled static modifier to compute the logical box, allocates + /// the buffer and LUT, and for each logical light asks the modifier chain for + /// physical destinations. Without a modifier AND with a dense grid in natural + /// order (no sparse, no serpentine, x-then-y-then-z) it sets an identity mapping + /// and skips the table entirely (the FPS floor for the common case). + /// Precondition: physicalWidth_/Height_/Depth_ must be set (call from onBuildState). void rebuildLUT() { lutSkipped_ = false; clearStatus(); // re-evaluated below if a degrade path is taken diff --git a/src/light/layers/Layers.h b/src/light/layers/Layers.h index 528f7596..3cfe590e 100644 --- a/src/light/layers/Layers.h +++ b/src/light/layers/Layers.h @@ -7,24 +7,22 @@ namespace mm { -// Top-level container for one or more `Layer` children. Each child Layer -// renders into its own buffer using the shared `Layouts` instance for physical -// topology. `Drivers` composites the resulting buffers in container order -// (bottom→top) per each Layer's blendMode + opacity. -// -// With one child Layer this is a thin pass-through: loop() runs the child -// Layer's loop() in order; behaviour matches the single-Layer pipeline -// byte-for-byte (Drivers takes its single-layer fast path). The container -// itself owns no buffer — the composite buffer lives in Drivers. +/// Top-level container for one or more `Layer` children. Each child Layer renders independently into its own buffer using the shared `Layouts` instance for physical topology. `Drivers` composites the resulting buffers in container order (bottom→top) per each Layer's blendMode and opacity. +/// +/// **Why a container:** multi-layer composition (alpha-blend, additive, layered overlays) needs a place to walk every layer in order so drivers can merge their buffers before consuming the result. With one child Layer this is a thin pass-through: loop() runs the child Layer's loop() in order; behaviour matches the single-Layer pipeline byte-for-byte (Drivers takes its single-layer fast path). +/// +/// **No buffer of its own:** each Layer owns its buffer and the `Drivers` container owns the composited output. Layers wires the shared `Layouts` into every child so each can size its buffer. Two queries serve the Drivers compositor: `activeLayer` (the first enabled child, or a disabled one as fallback) answers physical dimensions and feeds the single-layer fast path, and `forEachEnabledLayer` walks the enabled children in container order (bottom→top) marking the bottom layer that clears the buffer. `enabledLayerCount` lets Drivers pick the fast path (one enabled layer → hand its buffer straight to the driver) versus the composite path (≥2 → blend into the output buffer). +/// +/// **Prior art:** MoonLight's `PhysicalLayer` runs N `VirtualLayer`s and composites their buffers into the display channel — same idea, different shape: Drivers (not Layers) does the compositing here (https://github.com/ewowi/MoonLight/blob/main/src/MoonLight). class Layers : public MoonModule { public: const char* acceptsChildRoles() const override { return "layer"; } - // Wire the shared Layouts. Propagates to every child Layer so their - // onBuildState() can size buffers from it. Idempotent — call again - // after adding a Layer child to wire the new one. Non-Layer children - // (UI shouldn't allow them; engine doesn't enforce — yet) are skipped - // rather than miscast, so a stray child can't UB the cast. + /// Wire the shared Layouts. Propagates to every child Layer so their + /// onBuildState() can size buffers from it. Idempotent — call again + /// after adding a Layer child to wire the new one. Non-Layer children + /// (UI shouldn't allow them; engine doesn't enforce — yet) are skipped + /// rather than miscast, so a stray child can't UB the cast. void setLayouts(Layouts* l) { layouts_ = l; for (uint8_t i = 0; i < childCount(); i++) { @@ -36,20 +34,20 @@ class Layers : public MoonModule { Layouts* layouts() const { return layouts_; } - // Re-wire children before they build their state, so a Layer added via the - // API (clear_children + add_module) gets the shared Layouts without anyone - // re-running main.cpp's setLayouts. Then chain to base to build the children. + /// Re-wire children before they build their state, so a Layer added via the + /// API (clear_children + add_module) gets the shared Layouts without anyone + /// re-running main.cpp's setLayouts. Then chain to base to build the children. void onBuildState() override { setLayouts(layouts_); MoonModule::onBuildState(); } - // Role-filtered loop propagation: only tick children that are Layers. - // The factory / UI shouldn't allow non-Layer children of a Layers - // container, but if one slips in (test fixture, hand-crafted config), - // ticking it through Layers would run its loop at the wrong tree - // depth (e.g. an Effect that should be ticked inside a Layer). Matches - // the role-filter precedent in setLayouts / activeLayer above. + /// Role-filtered loop propagation: only tick children that are Layers. + /// The factory / UI shouldn't allow non-Layer children of a Layers + /// container, but if one slips in (test fixture, hand-crafted config), + /// ticking it through Layers would run its loop at the wrong tree + /// depth (an Effect that should be ticked inside a Layer). Matches + /// the role-filter precedent in setLayouts / activeLayer above. void loop() override { for (uint8_t i = 0; i < childCount(); i++) { MoonModule* c = child(i); @@ -61,11 +59,11 @@ class Layers : public MoonModule { } } - // The first enabled Layer — `Drivers` reads it for physical dimensions - // (every layer composites into the same physical space, so any one answers - // width/height/depth). Also the source for the single-layer fast path. - // Returns nullptr when no Layer is registered (drivers handle that gracefully). - // Non-Layer children are skipped — same guard as setLayouts above. + /// The first enabled Layer — `Drivers` reads it for physical dimensions + /// (every layer composites into the same physical space, so any one answers + /// width/height/depth). Also the source for the single-layer fast path. + /// Returns nullptr when no Layer is registered (drivers handle that gracefully). + /// Non-Layer children are skipped — same guard as setLayouts above. Layer* activeLayer() const { MoonModule* fallback = nullptr; for (uint8_t i = 0; i < childCount(); i++) { @@ -77,11 +75,11 @@ class Layers : public MoonModule { return static_cast<Layer*>(fallback); // nullptr if no Layer children } - // The first *enabled* Layer, or nullptr when none is enabled. Distinct from - // activeLayer(), which falls back to a disabled registered Layer so geometry - // (width/height/depth) stays queryable while everything is toggled off. Output - // selection must use *this* one: handing a disabled layer's stale buffer to the - // drivers would keep emitting its last frame instead of going idle. + /// The first *enabled* Layer, or nullptr when none is enabled. Distinct from + /// activeLayer(), which falls back to a disabled registered Layer so geometry + /// (width/height/depth) stays queryable while everything is toggled off. Output + /// selection must use *this* one: handing a disabled layer's stale buffer to the + /// drivers would keep emitting its last frame instead of going idle. Layer* firstEnabledLayer() const { for (uint8_t i = 0; i < childCount(); i++) { MoonModule* c = child(i); @@ -91,8 +89,8 @@ class Layers : public MoonModule { return nullptr; } - // Count of enabled Layer children — Drivers uses it to pick the single-layer - // fast path (==1) vs the composite path (>1), and to know if anything renders. + /// Count of enabled Layer children — Drivers uses it to pick the single-layer + /// fast path (==1) vs the composite path (>1), and to know if anything renders. uint8_t enabledLayerCount() const { uint8_t n = 0; for (uint8_t i = 0; i < childCount(); i++) { @@ -102,9 +100,9 @@ class Layers : public MoonModule { return n; } - // Walk enabled Layers in container (composition) order — the order Drivers - // blends them, bottom (first) to top (last). `cb(layer, isFirst)`: isFirst - // marks the bottom layer (clears the buffer; the rest blend onto it). + /// Walk enabled Layers in container (composition) order — the order Drivers + /// blends them, bottom (first) to top (last). `` `cb(layer, isFirst)` ``: isFirst + /// marks the bottom layer (clears the buffer; the rest blend onto it). template <typename Fn> void forEachEnabledLayer(Fn cb) const { bool first = true; diff --git a/src/light/layers/MappingLUT.h b/src/light/layers/MappingLUT.h index 3e125c34..fa5fcc62 100644 --- a/src/light/layers/MappingLUT.h +++ b/src/light/layers/MappingLUT.h @@ -7,20 +7,15 @@ namespace mm { -// CSR-style logical→physical map. `offsets_[li]..offsets_[li+1]` index a run of -// physical destinations for logical light `li`. Identity mode skips the table. -// -// The destinations array can be large for a many-to-one modifier on a big grid -// (a 128×128 XY mirror → 32768 entries × 2 B ≈ 64 KB). On a no-PSRAM ESP32 the -// largest *contiguous* free block can be smaller than that even when total free -// heap is fine — a fragmentation cliff, not exhaustion. So when a single block -// won't allocate but total heap allows it, destinations are split into -// fixed-size power-of-two PAGES that each fit a fragmented heap. Paging is the -// exception, not the rule: PSRAM boards (alloc is PSRAM-first → one huge block) -// and every no-PSRAM case where the single block fits keep the flat single -// array and the flat hot-path walk, byte-identical to a non-paged build. Only -// the one failing config (no-PSRAM + large grid + fragmented heap) pages, where -// the alternative is the modifier silently degrading to 1:1. +/// Lookup table mapping logical light indices to physical light indices. Four mapping types describe how logical lights relate to physical lights: 1:1 identical (logical index == physical index — a grid with no serpentine and no modifiers, no table needed), 1:1 shuffled (each logical → one physical, reordered — a serpentine grid), 1:0 unmapped (logical has no physical output — a sparse layout like a wheel), and 1:N multimap (logical → multiple physical — a mirror/clone modifier). The last three all need a table. +/// +/// **API:** the code API answers one question — does this LUT have a mapping table? `hasLUT` returns true when a table is allocated (1:1 shuffled, 1:0, 1:N). `setIdentity(count)` sets the table-free identity mode (`hasLUT` false; `forEachDestination(i, cb)` calls `cb(i)`). `build(logicalCount, maxDest)` allocates the CSR arrays. Callers don't need to know which mapping type is used — Drivers checks `hasLUT` to decide whether to allocate an output buffer, BlendMap checks it to choose between memcpy (identity) and LUT-based mapping. Naming: `setIdentity` / `hasLUT` rather than a "one-to-one" flag, because "one-to-one" reads as covering all 1:1 mappings, but the table-free fast path applies only to the *sequential identity* case. +/// +/// **Storage (CSR):** two arrays — `offsets_[li]..offsets_[li+1]` index a run of physical destinations for logical light `li`, and the destinations array holds the flat list of physical indices. `nrOfLightsType` is `uint16_t` on no-PSRAM, `uint32_t` on PSRAM. `totalDestinations` is provided by the `Layouts` container, so destinations are always within valid bounds. +/// +/// **Paged destinations (no-PSRAM fragmentation fallback):** the destinations array can be large for a many-to-one modifier on a big grid (a 128×128 XY mirror → 32768 entries × 2 B ≈ 64 KB). On a no-PSRAM ESP32 the largest *contiguous* free block can be smaller than that even when total free heap is fine — a fragmentation cliff, not exhaustion. So when a single block won't allocate but total heap allows it, destinations are split into fixed-size power-of-two PAGES that each fit a fragmented heap. Paging is the exception, not the rule: PSRAM boards (alloc is PSRAM-first → one huge block) and every no-PSRAM case where the single block fits keep the flat single array and the flat hot-path walk, byte-identical to a non-paged build. Only the one failing config (no-PSRAM + large grid + fragmented heap) pages, where the alternative is the modifier silently degrading to 1:1. `offsets_` is always a single small allocation; output is identical either way, so paging is purely an allocation detail. +/// +/// **Prior art:** MoonLight's `PhysMap` — a memory-optimal union (2 B no-PSRAM / 4 B PSRAM) with the map type stored in each entry, `oneToOneMapping` / `allOneLight` fast-path flags, and `forEachLightIndex` for 1:N iteration (https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/PhysMap.h). projectMM renames `oneToOneMapping` → `setIdentity` / `!hasLUT` for the reason above. class MappingLUT { public: MappingLUT() = default; @@ -29,28 +24,28 @@ class MappingLUT { MappingLUT(const MappingLUT&) = delete; MappingLUT& operator=(const MappingLUT&) = delete; - // Destinations page size. Power of two so the page split/index is a - // shift/mask (Xtensa has no hardware divide). 4096 entries × 2 B = 8 KB — - // small enough to fit a badly fragmented heap with margin, and to stay - // fittable as the heap shrinks with future modules. + /// Destinations page size. Power of two so the page split/index is a + /// shift/mask (Xtensa has no hardware divide). 4096 entries × 2 B = 8 KB — + /// small enough to fit a badly fragmented heap with margin, and to stay + /// fittable as the heap shrinks with future modules. static constexpr nrOfLightsType kPageEntries = 4096; static constexpr nrOfLightsType kPageShift = 12; // 1<<12 == 4096 static constexpr nrOfLightsType kPageMask = kPageEntries - 1; static constexpr int kMaxPages = 64; // 64 × 4096 = 256 K dests (512 KB) cap static_assert((kPageEntries & kPageMask) == 0, "kPageEntries must be a power of two"); - // Fast path: logical == physical, no table needed + /// Fast path: logical == physical, no table needed. `hasLUT` returns false. void setIdentity(nrOfLightsType count) { free(); identity_ = true; logicalCount_ = count; } - // Allocate CSR arrays for 1:N mapping. Returns false only on genuine - // exhaustion (tier 3) — the caller then degrades to 1:1. The three tiers: - // 1. single contiguous block fits → flat array (today's path) - // 2. no single block, total heap allows → paged array - // 3. total heap (minus reserve) too small → false (caller degrades) + /// Allocate CSR arrays for 1:N mapping. Returns false only on genuine + /// exhaustion (tier 3) — the caller then degrades to 1:1. The three tiers: + /// 1. single contiguous block fits → flat array (today's path) + /// 2. no single block, total heap allows → paged array + /// 3. total heap (minus reserve) too small → false (caller degrades) bool build(nrOfLightsType logicalCount, nrOfLightsType maxDestinations) { free(); identity_ = false; @@ -75,7 +70,7 @@ class MappingLUT { return true; } - // Fill one logical entry's destinations (call sequentially, idx 0..logicalCount-1) + /// Fill one logical entry's destinations (call sequentially, idx 0..logicalCount-1) void setMapping(nrOfLightsType logicalIdx, const nrOfLightsType* physicals, nrOfLightsType count) { if (!offsets_ || logicalIdx >= logicalCount_) return; offsets_[logicalIdx] = destinationCount_; @@ -85,7 +80,7 @@ class MappingLUT { } } - // Call after all setMapping calls to close the last offset + /// Call after all setMapping calls to close the last offset void finalize() { if (offsets_) { offsets_[logicalCount_] = destinationCount_; @@ -115,17 +110,18 @@ class MappingLUT { nrOfLightsType logicalCount() const { return logicalCount_; } nrOfLightsType destinationCount() const { return destinationCount_; } - // Whether each physical destination is written by at most one logical light. - // True for every current producer (mirror, serpentine shuffle, sparse - // box→driver) — their destinations are distinct, so blendMap can plain-copy - // (≈4× faster than the read-add-clamp additive path). Set false only for a - // map that intentionally folds multiple sources onto one destination (e.g. - // future multi-layer compositing), where additive blending is required. + /// Whether each physical destination is written by at most one logical light. + /// True for every current producer (mirror, serpentine shuffle, sparse + /// box→driver) — their destinations are distinct, so blendMap can plain-copy + /// (≈4× faster than the read-add-clamp additive path). Set false only for a + /// map that intentionally folds multiple sources onto one destination (for + /// example future multi-layer compositing), where additive blending is required. bool overwrites() const { return overwrites_; } void setOverwrites(bool v) { overwrites_ = v; } - // Memory accounting — actual bytes used, not capacity (destinations may be - // over-allocated). Paging doesn't change the total. + /// Memory accounting — actual bytes used, not capacity (destinations may be + /// over-allocated), 0 for identity. `estimateBytes` returns the total allocation + /// size for a prospective build. Paging doesn't change the total. size_t memoryUsed() const { if (identity_) return 0; return static_cast<size_t>(logicalCount_ + 1) * sizeof(nrOfLightsType) @@ -137,7 +133,9 @@ class MappingLUT { + static_cast<size_t>(maxDest) * sizeof(nrOfLightsType); } - // Hot-path: iterate physical destinations for a logical index. + /// Hot-path: iterate physical destinations for a logical index. In identity mode + /// it calls back with the logical index itself (no table read); otherwise it walks + /// the CSR run, switching pages at each 4096 boundary in the paged case. template<typename F> void forEachDestination(nrOfLightsType logicalIdx, F&& callback) const { if (identity_) { diff --git a/src/light/layouts/Layouts.h b/src/light/layouts/Layouts.h index 142334af..cbeae521 100644 --- a/src/light/layouts/Layouts.h +++ b/src/light/layouts/Layouts.h @@ -7,41 +7,52 @@ namespace mm { -// Callback for layout coordinate iteration — a layout walks its positions and -// invokes this per light with the physical index and (x,y,z). Owned by -// LayoutBase: it's the signature of forEachCoord, which every layout overrides. +/// Callback for layout coordinate iteration — a layout walks its positions and +/// invokes this per light with the physical index and (x,y,z). Owned by +/// LayoutBase: it's the signature of forEachCoord, which every layout overrides. using CoordCallback = void(*)(void* ctx, nrOfLightsType idx, lengthType x, lengthType y, lengthType z); +/// Base for one layout child of the `Layouts` container. A concrete layout +/// (grid, sphere shell, …) implements `lightCount` and `forEachCoord` directly — +/// no wrapper. Every layout control changes the physical light count, so any +/// control change triggers the pipeline-wide rebuild. class LayoutBase : public MoonModule { public: ModuleRole role() const override { return ModuleRole::Layout; } virtual nrOfLightsType lightCount() const = 0; virtual void forEachCoord(CoordCallback cb, void* ctx) const = 0; - // Every layout control (grid width/height/depth, …) changes the physical light - // count and therefore needs the pipeline-wide rebuild. See MoonModule::onUpdate. + /// Every layout control (grid width/height/depth, …) changes the physical light + /// count and therefore needs the pipeline-wide rebuild. See MoonModule::onUpdate. bool controlChangeTriggersBuildState(const char* /*controlName*/) const override { return true; } }; -// Top-level container for one or more `LayoutBase` children. Walks them in -// registration order, stitching per-child light indices into a single flat -// physical address space via `forEachCoord`. Shared by every Layer — there's -// one Layouts describing the physical setup, multiple Layers render into it. +/// Top-level container for one or more `LayoutBase` children — it defines the physical light topology of the installation and is shared by every Layer in the `Layers` container (one Layouts describing the physical setup, multiple Layers render into it). +/// +/// **Coordinate iteration is owned by the container, not the layer:** `forEachCoord` walks every enabled child layout's coordinates in registration order, offsetting physical indices so multiple layouts (for example 16 strips making one panel) stitch into a single flat physical address space without overlap. A Layer *uses* those coordinates to build its LUT. `totalLightCount` (the sum across enabled children) sizes both the layer buffer and the driver output buffer. +/// +/// **Disabling a layout:** disabling a layout child (the `enabled` toggle) removes its lights from the LUT entirely, and the indices of any layouts after it shift down to close the gap — with two grids of 4 and 2 lights, disabling the first leaves the second at indices 0–1 and `totalLightCount` drops from 6 to 2. A `Scheduler::buildState` fires so the LUT, layer buffer, and driver output buffer reallocate. Side effect: ArtNet universe assignments shift with the indices — to keep driver-to-fixture mapping stable across enable changes, disable the driver instead of the layout. Disabling the container itself reports zero lights and an empty iteration, the same effect as disabling every child. +/// +/// **Reordering:** layout children reorder by drag-and-drop (`POST /api/modules/<name>/move` with `{"to": <index>}`), with insert (not swap) semantics — the standard reorderable-list behaviour (Finder, Trello, SortableJS). Order sets the physical index range each layout occupies, which drives ArtNet universe assignment. The same `move` op applies to every container. +/// +/// **Status:** the status slot shows the physical setup it describes — `` `<N> lights · <W>×<H>×<D>` `` — the total light count summed across enabled children (the driver output buffer size) and the physical bounding box (the extent of all light coordinates, the dense render buffer size). For a dense grid the count equals the box volume; for a sparse layout (a sphere shell) the count is smaller than the box, and that gap is the at-a-glance signal that the layout is sparse. An empty setup reports Warning severity. Recomputed on every rebuild, not per tick. class Layouts : public MoonModule { public: const char* acceptsChildRoles() const override { return "layout"; } - // Disabled children are skipped, same gate Layer/Layers/Drivers apply to their - // children. Indices of subsequent enabled layouts shift down to close the gap — - // disable Layout A and Layout B's lights move to indices 0..N. Users who need - // a stable index-to-fixture mapping disable the driver, not the layout. - // - // Disabling the container itself reports zero lights and an empty iteration — - // same effect as disabling every child, so the universal-gate intent ("enabled - // on every module means: exclude my contribution") holds for the container too. - // The Scheduler can't enforce this for us because Layouts has no loop() — the - // work happens in these cold-path methods called from Layer::onBuildState - // and Drivers::onBuildState. + /// Sum of `lightCount` across enabled children — sizes the layer buffer and the + /// driver output buffer. Disabled children are skipped, the same gate + /// Layer/Layers/Drivers apply to their children. Indices of subsequent enabled + /// layouts shift down to close the gap — disable Layout A and Layout B's lights + /// move to indices 0..N. Users who need a stable index-to-fixture mapping disable + /// the driver, not the layout. + /// + /// Disabling the container itself reports zero lights and an empty iteration — + /// same effect as disabling every child, so the universal-gate intent ("enabled + /// on every module means: exclude my contribution") holds for the container too. + /// The Scheduler can't enforce this for us because Layouts has no loop() — the + /// work happens in these cold-path methods called from Layer::onBuildState + /// and Drivers::onBuildState. nrOfLightsType totalLightCount() const { if (!enabled()) return 0; nrOfLightsType total = 0; @@ -73,12 +84,12 @@ class Layouts : public MoonModule { } } - // Status line: total physical lights + the physical bounding box (the extent - // of all light coordinates). Both are derived facts the container owns — the - // count is the driver buffer size, the box is the dense render extent. Shown - // via the status slot (not controls) so it costs no spec-check entry and - // renders generically. Recomputed only on a rebuild (cold path). A degenerate - // setup (no lights / zero box) flags Warning so the UI shows it's empty. + /// Status line: total physical lights + the physical bounding box (the extent + /// of all light coordinates). Both are derived facts the container owns — the + /// count is the driver buffer size, the box is the dense render extent. Shown + /// via the status slot (not controls) so it costs no spec-check entry and + /// renders generically. Recomputed only on a rebuild (cold path). A degenerate + /// setup (no lights / zero box) flags Warning so the UI shows it's empty. void onBuildState() override { const nrOfLightsType lights = totalLightCount(); // One forEachCoord pass for the bounding box: max coordinate + 1 per axis. diff --git a/src/light/modifiers/ModifierBase.h b/src/light/modifiers/ModifierBase.h index b010b9e0..9ad6c98f 100644 --- a/src/light/modifiers/ModifierBase.h +++ b/src/light/modifiers/ModifierBase.h @@ -5,78 +5,70 @@ namespace mm { +/// Light-domain MoonModule base for modifiers. A modifier is a **coordinate transform** that reshapes how a Layer's effect output maps onto the physical lights. Multiple modifiers on one Layer **compose**: they apply in child order, each reshaping the result of the one below (Region *then* Multiply-mirror *then* Rotate). +/// +/// **The fold contract:** a Layer builds its mapping by walking the physical lights and folding each through every enabled modifier in order — the composition M₁∘M₂∘…∘Mₙ collapsed into one mapping, so the per-frame render stays a single lookup. Three hooks, each a no-op by default so a modifier implements only what it needs: `modifyLogicalSize` (static, once per rebuild, reshapes the running logical box), `modifyLogical` (static, per physical light, folds a coordinate into this stage's logical space, returns false to reject), and `modifyLive` (dynamic, per-frame, a backward map that remaps a coordinate without rebuilding). A beat-driven modifier sets a flag in `loop`; the Layer polls `consumeNeedsRebuild` and rebuilds the mapping once if any asks. `dimensions` advertises which axes the modifier can transform. +/// +/// **Fan-out is free:** because the build walks physical lights, fan-out (one logical cell driving N physical lights — a Multiply kaleidoscope) emerges naturally: N physical lights fold onto the same logical cell. There is no build-time fan-out list and no product-of-multipliers ceiling — each physical light contributes at most one destination, so the mapping can never overflow. +/// +/// **Affine modifiers:** most modifiers are non-affine (a mask is a predicate, a tile is modulo) and express their fold directly. The rotate modifier is the exception and the codebase's transform-matrix reference: rotation is the canonical affine transform, written as an explicit integer 2×2 rotation matrix in `modifyLive`. A future affine "Transform" modifier (translate+scale+rotate+shear in one) would compose its matrix the same way and apply it through the same hook — the fold interface hosts a matrix-backed modifier with no change. +/// +/// **Prior art:** the two building blocks are the textbook image-warping pattern — bake a coordinate transform into a precomputed spatial LUT, and build that table by BACKWARD mapping (walk the destinations, find each one's source) so no output pixel is ever left unmapped (https://towardsdatascience.com/forward-and-backward-mapping-for-computer-vision-833436e2472/). What is specific here — and credited to MoonLight — is collapsing a whole *chain* of discrete pixel folds into ONE index table: a PC node graph (TouchDesigner, shader graphs) gives each node its own frame buffer; an ESP32 can't spare a buffer per modifier, so the chain is folded into a single LUT and the hot path stays one gather. MoonLight's `modifySize` / `modifyPosition` / `modifyXYZ` map to our `modifyLogicalSize` / `modifyLogical` / `modifyLive`, written fresh against our `MappingLUT` (https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h). class ModifierBase : public MoonModule { public: ModuleRole role() const override { return ModuleRole::Modifier; } - // A modifier control change alters the mapping, so the owning Layer must rebuild - // it — the pipeline-wide rebuild path. See MoonModule::onUpdate. + /// A modifier control change alters the mapping, so the owning Layer must rebuild + /// it — the pipeline-wide rebuild path. See MoonModule::onUpdate. bool controlChangeTriggersBuildState(const char* /*controlName*/) const override { return true; } - // Which axes the modifier can transform. Defaults to D3 — a modifier that - // touches the mapping is assumed to work in 3D unless it declares otherwise. - // The UI uses this to render the 📏/🟦/🧊 chip so the user can see at a - // glance whether a modifier will do anything along z. Distinct from - // EffectBase::dimensions() (which controls Layer extrusion); here it is - // purely an advisory chip, never read in the render path. + /// Which axes the modifier can transform. Defaults to D3 — a modifier that + /// touches the mapping is assumed to work in 3D unless it declares otherwise. + /// The UI uses this to render the 📏/🟦/🧊 chip so the user can see at a + /// glance whether a modifier will do anything along z. Distinct from + /// EffectBase::dimensions() (which controls Layer extrusion); here it is + /// purely an advisory chip, never read in the render path. virtual Dim dimensions() const { return Dim::D3; } - // --- Fold interface (composable modifiers) --------------------------------- - // A modifier is a coordinate transform. The Layer builds its mapping by walking - // the PHYSICAL lights and folding each through every enabled modifier in child - // order — the result is the chain M₁∘M₂∘…∘Mₙ collapsed into one mapping, so - // ordering several modifiers on a Layer "just works" (Region then Multiply- - // mirror then Rotate). Three tiers, each a no-op by default so a modifier - // implements only the ones it needs. - // - // Prior art: the two building blocks are the textbook image-warping pattern — - // bake a coordinate transform into a precomputed spatial LUT (geometry is - // static, so compute the table once and only read it per frame), and build that - // table by BACKWARD mapping (walk the destinations, find each one's source) so - // no output pixel is ever left unmapped. What is specific here — and credited to - // MoonLight (M_MoonLight.h), the prior engine this is modelled on — is collapsing - // a whole *chain* of discrete pixel folds into ONE index table. A PC node graph - // (TouchDesigner, shader graphs) gives each node its own frame buffer; an ESP32 - // can't spare a buffer per modifier, so the chain is folded into a single LUT - // and the hot path stays one gather. That MCU-budget synthesis is the borrowed - // idea, written fresh against our MappingLUT here. + // --- Fold interface (composable modifiers) — see the class comment for the + // composition contract and prior art ------------------------------------- - // STATIC, build-time, once per rebuild in child order: fold the logical box. + /// STATIC, build-time, once per rebuild in child order: fold the logical box. // Multiply divides it, Region crops it, a mask leaves it. `size` is the running - // logical box (starts at the physical box; each modifier reshapes it for the - // next). A modifier that needs the box in its per-light fold STASHES it here - // (the MoonLight `modifierSize` pattern) — so modifyLogical reads its own stage's - // box from itself, and the Layer needs no per-stage box array. Non-const: the - // stash mutates the modifier. + /// logical box (starts at the physical box; each modifier reshapes it for the + /// next). A modifier that needs the box in its per-light fold STASHES it here + /// (the MoonLight `modifierSize` pattern) — so modifyLogical reads its own stage's + /// box from itself, and the Layer needs no per-stage box array. Non-const: the + /// stash mutates the modifier. virtual void modifyLogicalSize(Coord3D& /*size*/) {} - // STATIC, build-time, per physical light in child order: fold a coordinate into - // this stage's logical space (in place). The coord enters in the box this - // modifier saw at modifyLogicalSize time and leaves in the box it produced, so - // the next modifier in the chain continues from here. Return false to REJECT — - // the coordinate has no logical source (a mask drops it, a region light falls - // outside the crop). A bool, not a sentinel coord: a later modifier's `% size` - // can't alias a sentinel back into range. The modifier reads any box it needs - // from its own stash (see modifyLogicalSize). + /// STATIC, build-time, per physical light in child order: fold a coordinate into + /// this stage's logical space (in place). The coord enters in the box this + /// modifier saw at modifyLogicalSize time and leaves in the box it produced, so + /// the next modifier in the chain continues from here. Return false to REJECT — + /// the coordinate has no logical source (a mask drops it, a region light falls + /// outside the crop). A bool, not a sentinel coord: a later modifier's `` `% size` `` + /// can't alias a sentinel back into range. The modifier reads any box it needs + /// from its own stash (see modifyLogicalSize). virtual bool modifyLogical(Coord3D& /*pos*/) const { return true; } - // DYNAMIC, per-frame at render time: remap a coordinate without rebuilding the - // mapping (smooth rotation/scroll). The Layer runs this pass ONLY when some - // enabled modifier overrides it (hasModifyLive()), so a static-only chain pays - // nothing per frame — the render path stays at full speed. `logical` is the box. + /// DYNAMIC, per-frame at render time: remap a coordinate without rebuilding the + /// mapping (smooth rotation/scroll). The Layer runs this pass ONLY when some + /// enabled modifier overrides it (hasModifyLive()), so a static-only chain pays + /// nothing per frame — the render path stays at full speed. `logical` is the box. virtual void modifyLive(Coord3D& /*pos*/, const Coord3D& /*logical*/) const {} - // True iff this modifier does per-frame work (overrides modifyLive). The Layer - // sums this across enabled modifiers at build time to gate the per-frame pass; - // a modifier that animates returns true so the seam runs only when needed. + /// True iff this modifier does per-frame work (overrides modifyLive). The Layer + /// sums this across enabled modifiers at build time to gate the per-frame pass; + /// a modifier that animates returns true so the seam runs only when needed. virtual bool hasModifyLive() const { return false; } - // A modifier whose mapping changes on a timer (RandomMap reshuffles on a beat) - // sets a flag in its loop(); the Layer polls this once per frame across all its - // enabled modifiers and rebuilds the mapping ONCE if any returns true — so - // several dynamic modifiers ticking together coalesce to a single rebuild rather - // than each re-entering onBuildState(). Returns true at most once per change, - // clearing the flag. Default false: a static modifier never asks for a rebuild. + /// A modifier whose mapping changes on a timer (RandomMap reshuffles on a beat) + /// sets a flag in its loop(); the Layer polls this once per frame across all its + /// enabled modifiers and rebuilds the mapping ONCE if any returns true — so + /// several dynamic modifiers ticking together coalesce to a single rebuild rather + /// than each re-entering onBuildState(). Returns true at most once per change, + /// clearing the flag. Default false: a static modifier never asks for a rebuild. virtual bool consumeNeedsRebuild() { return false; } }; diff --git a/src/ui/app.js b/src/ui/app.js index ecb74871..61138bdb 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -507,8 +507,14 @@ function createCard(mod, depth) { title.appendChild(actions); } - // Help link → the module's spec page on GitHub, at the far right of the row. - // docPath comes from /api/types (relative to docs/moonmodules/); omitted if none. + // Help link → the module's spec page on the rendered docs site, far right of + // the row. docPath comes from /api/types (relative to docs/moonmodules/, e.g. + // "core/AudioModule.md" or "light/effects/effects.md#fire"); omitted if none. + // The site is Material for MkDocs at moonmodules.org/projectMM/ (flat URLs, so + // foo.md → foo.html; the MkDocs heading slugs match these #anchors), reached + // via the same /projectMM/ subpath the installer uses. Convert only the `.md` + // extension that sits right before the optional `#anchor` (suffix-anchored), + // so a docPath that ever contained ".md" mid-string wouldn't be mangled. const docPath = docPathForType(mod.type); if (docPath) { const help = document.createElement("a"); @@ -517,7 +523,8 @@ function createCard(mod, depth) { help.title = "Open module documentation"; help.target = "_blank"; help.rel = "noopener"; - help.href = "https://github.com/MoonModules/projectMM/blob/main/docs/moonmodules/" + docPath; + const htmlPath = docPath.replace(/\.md(#.*)?$/, ".html$1"); + help.href = "https://moonmodules.org/projectMM/moonmodules/" + htmlPath; title.appendChild(help); } diff --git a/src/ui/install-picker-boards.js b/src/ui/install-picker-boards.js index 69179d30..55b72666 100644 --- a/src/ui/install-picker-boards.js +++ b/src/ui/install-picker-boards.js @@ -1,6 +1,6 @@ // install-picker-boards.js — the board-catalog + chip-detection half of the // release picker. WEB-INSTALLER ONLY. It is imported by the GitHub Pages -// installer (docs/install/index.html) and passed into installPicker.init() as +// installer (web-installer/index.html) and passed into installPicker.init() as // the `boardSupport` option; the shared install-picker.js never imports it. // // Why a separate file: install-picker.js is embedded into the firmware binary @@ -17,7 +17,7 @@ // install-orchestrator.js and reach this code only via the onDetect callback // install-picker.js already owns). -// Boards catalog — same-origin docs/install/deviceModels.json. ~1 KB, no rate-limit +// Boards catalog — same-origin web-installer/deviceModels.json. ~1 KB, no rate-limit // concern (CDN serves it on the public site, preview_installer serves it from // disk locally), so no sessionStorage cache: caching adds invalidation bugs // without saving bytes. Graceful degradation: any fetch / parse failure returns diff --git a/src/ui/install-picker.js b/src/ui/install-picker.js index 9d70e6c2..2233322a 100644 --- a/src/ui/install-picker.js +++ b/src/ui/install-picker.js @@ -81,7 +81,7 @@ function makeState() { sortedReleases: [], // releases sorted newest-first; render() fills this releaseIdx: 0, // index into sortedReleases firmware: null, // selected firmware key - boards: [], // parsed docs/install/deviceModels.json, [] if unavailable + boards: [], // parsed web-installer/deviceModels.json, [] if unavailable selectedBoard: null, // user pick from board <select>; "" for (any board) hasPort: null, // web installer only: () => bool, "is a USB port // picked?". When set, Install is disabled until it @@ -267,7 +267,7 @@ function relativeTime(iso) { // Uses the same `.control-row` / `.control-label` / `<select>` shape as the // rest of `createControl()` in app.js so the picker visually integrates with // the card it's mounted in. The web installer overrides these with its own -// styles in docs/install/index.html, which gives the installer page the same +// styles in web-installer/index.html, which gives the installer page the same // look without app.js loading. // Draw the field rows immediately, before the network fetches resolve, so the // user sees the full form straight away instead of a lone "Loading…" line that diff --git a/test/js/config-ops.test.mjs b/test/js/config-ops.test.mjs index fcc982c1..c84aefd0 100644 --- a/test/js/config-ops.test.mjs +++ b/test/js/config-ops.test.mjs @@ -16,10 +16,10 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; -import { planConfigOps } from "../../docs/install/config-ops.js"; +import { planConfigOps } from "../../web-installer/config-ops.js"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); -const catalog = JSON.parse(readFileSync(join(ROOT, "docs", "install", "deviceModels.json"), "utf8")); +const catalog = JSON.parse(readFileSync(join(ROOT, "web-installer", "deviceModels.json"), "utf8")); // Indices of the first op of each kind, so we can assert global ordering. const firstIndex = (ops, pred) => ops.findIndex(pred); diff --git a/test/js/improv-frame.test.mjs b/test/js/improv-frame.test.mjs index 54440945..b5185098 100644 --- a/test/js/improv-frame.test.mjs +++ b/test/js/improv-frame.test.mjs @@ -1,6 +1,6 @@ // Improv frame-contract tests — pin the wire format the device C++ // (src/core/ImprovFrame.h), Python (scripts/build/improv_provision.py), and the -// installer JS (docs/install/improv-frame.js) must all agree on byte-for-byte. +// installer JS (web-installer/improv-frame.js) must all agree on byte-for-byte. // The golden vectors here are asserted identically in test/python/test_improv_frame.py // so the JS and Python builders can't drift; they're hand-verified against the C++ // checksum (sum-mod-256) too. Run: `node --test test/js`. @@ -14,7 +14,7 @@ import { IMPROV_CMD_APPLY_OP, IMPROV_FRAME_TYPE_RPC, IMPROV_MAGIC, -} from "../../docs/install/improv-frame.js"; +} from "../../web-installer/improv-frame.js"; const hex = (u8) => Array.from(u8).map((b) => b.toString(16).padStart(2, "0")).join(" "); const bytes = (s) => Array.from(new TextEncoder().encode(s)); diff --git a/test/js/installer-eth-only.test.mjs b/test/js/installer-eth-only.test.mjs index 776dc6a0..b7f47340 100644 --- a/test/js/installer-eth-only.test.mjs +++ b/test/js/installer-eth-only.test.mjs @@ -1,5 +1,5 @@ // Installer eth-only contract — the rule the web installer uses to decide whether a firmware is -// Ethernet-only must agree with docs/install/firmwares.json's `eth_only` flag for EVERY firmware. +// Ethernet-only must agree with web-installer/firmwares.json's `eth_only` flag for EVERY firmware. // // Why this matters: an Ethernet-only build (esp32-eth, esp32p4-eth) has WiFi compiled out, so it has // no WIFI_SETTINGS Improv RPC. If the installer mis-classifies it as WiFi-capable and the device has @@ -21,7 +21,7 @@ import { dirname, join } from "node:path"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const firmwares = JSON.parse( - readFileSync(join(ROOT, "docs", "install", "firmwares.json"), "utf8") + readFileSync(join(ROOT, "web-installer", "firmwares.json"), "utf8") ).firmwares; // The single rule the installer applies (index.html onInstall): a firmware key ending in `-eth` is @@ -37,7 +37,7 @@ test("the installer's /-eth$/ rule matches firmwares.json eth_only for every fir `firmware "${f.name}": name-rule says eth-only=${isEthOnlyByName(f.name)} but ` + `firmwares.json eth_only=${!!f.eth_only}. The installer would make the wrong ` + `WiFi-provisioning decision (eth-only builds have no WIFI_SETTINGS RPC → UNKNOWN_RPC). ` + - `Reconcile the /-eth$/ rule in docs/install/index.html with this firmware's name/flag.` + `Reconcile the /-eth$/ rule in web-installer/index.html with this firmware's name/flag.` ); } }); diff --git a/test/js/installer-s31-webflash.test.mjs b/test/js/installer-s31-webflash.test.mjs index e78f35ee..ee67184b 100644 --- a/test/js/installer-s31-webflash.test.mjs +++ b/test/js/installer-s31-webflash.test.mjs @@ -24,9 +24,9 @@ import { dirname, join } from "node:path"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); -const installJs = readFileSync(join(ROOT, "docs", "install", "install.js"), "utf8"); +const installJs = readFileSync(join(ROOT, "web-installer", "install.js"), "utf8"); const boards = JSON.parse( - readFileSync(join(ROOT, "docs", "install", "deviceModels.json"), "utf8") + readFileSync(join(ROOT, "web-installer", "deviceModels.json"), "utf8") ); // The chip families install.js flags as not-browser-flashable. Parsed from the diff --git a/test/python/test_check_specs_drift.py b/test/python/test_check_specs_drift.py new file mode 100644 index 00000000..11d3713a --- /dev/null +++ b/test/python/test_check_specs_drift.py @@ -0,0 +1,71 @@ +"""The spec-drift checks in check_specs.py must catch a control range or an author +URL that changed in the .h but not the .md — and NOT false-alarm on the common +cases (a control whose prose omits the range, a range spelled with a hyphen vs +en-dash). These are the two Phase-3 drift guards; they run inside check_spec_freshness +on every commit (via the spec-check gate), so a wrong range/URL in a doc is caught at +commit instead of shipping. This test pins them against synthetic .h/.md text. +""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(ROOT / "scripts" / "check")) + +from check_specs import _check_range_drift, _check_author_url_drift # noqa: E402 + + +# ---- range drift ---- + +def test_range_drift_flags_a_conflicting_range(): + src = 'controls_.addUint8("floor", floor, 0, 255);' + md = "- `floor` — noise floor (0–128)." # .md says 0–128, .h says 0–255 + issues = _check_range_drift(src, md) + assert issues and "floor" in issues[0] and "0–255" in issues[0] + + +def test_range_drift_silent_when_ranges_match(): + src = 'controls_.addUint8("freq_x", freq_x, 1, 8);' + md = "- `freq_x` — wave frequency (1–8)." + assert _check_range_drift(src, md) == [] + + +def test_range_drift_tolerates_hyphen_and_to_spellings(): + src = 'controls_.addUint8("count", count, 1, 255);' + for prose in ("(1-255)", "1 to 255", "(1–255)"): + md = f"- `count` — rings {prose}." + assert _check_range_drift(src, md) == [], prose + + +def test_range_drift_silent_when_prose_states_no_range(): + # Many controls legitimately don't restate their range (pins, obvious 0–255). + src = 'controls_.addUint8("gain", gain, 1, 255);' + md = "- `gain` — microphone gain." + assert _check_range_drift(src, md) == [] + + +def test_range_drift_ignores_non_range_controls(): + # addBool / addSelect / addPin carry no numeric range → nothing to check. + src = 'controls_.addBool("enabled", enabled);\ncontrols_.addPin("sdPin", sdPin);' + assert _check_range_drift(src, "- `enabled` — on/off. - `sdPin` — data pin.") == [] + + +# ---- author-URL drift ---- + +def test_author_url_drift_flags_missing_url(): + src = "// Author: Someone — https://github.com/acme/thing" + md = "Origin: Someone · a different link https://github.com/acme/OTHER" + issues = _check_author_url_drift(src, md) + assert issues and "acme/thing" in issues[0] + + +def test_author_url_drift_silent_when_url_present(): + src = "// Author: Someone — https://github.com/acme/thing" + md = "Origin: Someone · source [thing](https://github.com/acme/thing)" + assert _check_author_url_drift(src, md) == [] + + +def test_author_url_drift_silent_when_no_url_in_header(): + # An Author line without a URL (a plain credit) has nothing to sync. + src = "// Author: FastLED inoise field (Mark Kriegsman)" + assert _check_author_url_drift(src, "Origin: FastLED · inoise field") == [] diff --git a/test/python/test_improv_frame.py b/test/python/test_improv_frame.py index d84c6fcc..d327ea81 100644 --- a/test/python/test_improv_frame.py +++ b/test/python/test_improv_frame.py @@ -4,7 +4,7 @@ """Improv frame-contract tests (Python side). Pins the wire format the device C++ (src/core/ImprovFrame.h), the installer JS -(docs/install/improv-frame.js), and this Python builder (scripts/build/improv_provision.py) +(web-installer/improv-frame.js), and this Python builder (scripts/build/improv_provision.py) must all agree on byte-for-byte. The G1 golden vector below is the SAME one asserted in test/js/improv-frame.test.mjs, so the JS and Python envelope builders can't drift; it's hand-verified against the C++ sum-mod-256 checksum too. diff --git a/test/python/test_installer_manifests.py b/test/python/test_installer_manifests.py index 524445cb..561ff019 100644 --- a/test/python/test_installer_manifests.py +++ b/test/python/test_installer_manifests.py @@ -12,7 +12,7 @@ file the deploy didn't stage, or a shipping firmware with no manifest at all) shipped a broken v2.0.0 installer; this test pins the contract so it can't recur: - 1. every `ships: true` firmware in docs/install/firmwares.json generates a valid manifest, and + 1. every `ships: true` firmware in web-installer/firmwares.json generates a valid manifest, and 2. every part path in every manifest matches one of the staged-file globs above. Self-contained: it feeds generate_manifest.py a synthetic flasher_args.json (the real one only @@ -28,7 +28,7 @@ import pytest ROOT = Path(__file__).resolve().parent.parent.parent -FIRMWARES_JSON = ROOT / "docs" / "install" / "firmwares.json" +FIRMWARES_JSON = ROOT / "web-installer" / "firmwares.json" GENERATE = ROOT / "scripts" / "build" / "generate_manifest.py" # The exact globs the deploy's `gh release download` stages onto Pages (release.yml, diff --git a/test/python/test_mkdocs_slug.py b/test/python/test_mkdocs_slug.py new file mode 100644 index 00000000..f8e348ea --- /dev/null +++ b/test/python/test_mkdocs_slug.py @@ -0,0 +1,48 @@ +"""The catalog-table hook builds a "⌄ details" anchor link, `#{_slug(name + ' — details')}`, +that must land on the `## <Name> — details` heading MkDocs renders on the catalog pages. +Because `validation.links.anchors` is `warn` (not `fail`), a slug that diverges from the +real heading id produces a SILENT dead link, not a build failure — so this test pins +`_slug()` to the actual anchor Python-Markdown generates. + +`_slug()` delegates to `markdown.extensions.toc.slugify` (the exact function MkDocs' toc +uses), so this both guards that delegation and documents the contract. If `_slug` is ever +re-hand-rolled, the unicode / consecutive-separator cases below catch the drift. +""" + +import sys +from pathlib import Path + +import pytest + +# `markdown` ships with MkDocs but isn't in the base test env — skip cleanly rather +# than error collection where it's absent. CI installs it (--with markdown) so the +# test actually runs there; a contributor without it just skips this one file. +markdown = pytest.importorskip("markdown") +from markdown.extensions.toc import slugify # noqa: E402 + +ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(ROOT / "scripts" / "docs")) + +from mkdocs_hooks import _slug # noqa: E402 + + +def test_slug_matches_markdown_toc_slugify(): + """_slug is exactly Python-Markdown's toc slugify — including the edge cases a + hand-rolled mimic gets wrong (accent-stripping, decoration, repeated separators).""" + for text in [ + "LED output — details", + "Fire2012 — details", + "GEQ · 3D — details", + "a b c", + "Émoji 💫 test", + "Multiple---hyphens", + ]: + assert _slug(text) == slugify(text, "-"), text + + +def test_slug_resolves_to_the_real_rendered_heading_id(): + """A `## <heading>` rendered by the same toc extension MkDocs uses gets an + `id=` equal to _slug(heading) — so the hand-built anchor actually resolves.""" + for heading in ["LED output — details", "GEQ · 3D — details"]: + html = markdown.markdown(f"## {heading}", extensions=["toc"]) + assert f'id="{_slug(heading)}"' in html, heading diff --git a/test/scenarios/light/scenario_GridLayout_resize.json b/test/scenarios/light/scenario_GridLayout_resize.json index d30ea142..b3f86804 100644 --- a/test/scenarios/light/scenario_GridLayout_resize.json +++ b/test/scenarios/light/scenario_GridLayout_resize.json @@ -118,7 +118,7 @@ "pc-macos": { "tick_us": [ 95, - 283 + 317 ], "free_heap": [ 0, @@ -130,7 +130,7 @@ ], "at": [ "2026-06-02", - "2026-06-03" + "2026-07-02" ] }, "esp32-eth-wifi": { @@ -420,7 +420,7 @@ "pc-macos": { "tick_us": [ 98, - 307 + 318 ], "free_heap": [ 0, @@ -432,7 +432,7 @@ ], "at": [ "2026-06-02", - "2026-06-03" + "2026-07-02" ] }, "esp32-eth-wifi": { diff --git a/test/scenarios/light/scenario_MultiplyModifier_pipeline.json b/test/scenarios/light/scenario_MultiplyModifier_pipeline.json index 93f031df..16314db1 100644 --- a/test/scenarios/light/scenario_MultiplyModifier_pipeline.json +++ b/test/scenarios/light/scenario_MultiplyModifier_pipeline.json @@ -90,7 +90,7 @@ "pc-macos": { "tick_us": [ 1, - 272 + 319 ], "free_heap": [ 0, @@ -102,7 +102,7 @@ ], "at": [ "2026-06-02", - "2026-06-25" + "2026-07-02" ] }, "pc-windows": { diff --git a/test/unit/light/unit_BlockModifier.cpp b/test/unit/light/unit_BlockModifier.cpp new file mode 100644 index 00000000..914c226c --- /dev/null +++ b/test/unit/light/unit_BlockModifier.cpp @@ -0,0 +1,77 @@ +// @module BlockModifier + +#include "doctest.h" +#include "light/modifiers/BlockModifier.h" + +// BlockModifier is a 1D->2D remap: every physical light folds to its CHEBYSHEV +// (block) distance from the floor-biased box centre — max(|dx|,|dy|) — written into +// y (pos becomes {0, distance, 0}), so a 1D effect painted along y draws concentric +// SQUARE rings. Z plays no part. modifyLogicalSize folds the box itself the same way +// and grows each axis by one, yielding the {1, maxDistance + 1, 1} logical box. + +// The logical box a BlockModifier produces for a given physical box. +static mm::Coord3D blockSize(mm::BlockModifier& b, mm::Coord3D box) { + b.modifyLogicalSize(box); + return box; +} + +// Fold a physical coord through the modifier. Stashes the box first (modifyLogicalSize +// saves the physical box the const fold reads for its centre), then folds (x,y,z). +static mm::Coord3D fold(mm::BlockModifier& b, mm::lengthType x, mm::lengthType y, + mm::lengthType z, mm::Coord3D box) { + b.modifyLogicalSize(box); // stashes the physical box + mm::Coord3D p{x, y, z}; + b.modifyLogical(p); // never rejects — returns true, we assert the coord + return p; +} + +// The centre light of the box folds to distance 0 (the innermost ring, y=0); a corner +// folds to the largest distance, and z is always cleared to 0. +TEST_CASE("BlockModifier folds to Chebyshev distance from the box centre") { + mm::BlockModifier b; + // On a 4x4 box the floor-biased centre is (1,1). The centre light maps to y=0. + CHECK(fold(b, 1, 1, 0, {4, 4, 1}) == mm::Coord3D{0, 0, 0}); + // Corner (0,0): dx=dy=1 -> distance 1. + CHECK(fold(b, 0, 0, 0, {4, 4, 1}) == mm::Coord3D{0, 1, 0}); + // Far corner (3,3): dx=dy=2 -> distance 2 (the outermost square ring). + CHECK(fold(b, 3, 3, 0, {4, 4, 1}) == mm::Coord3D{0, 2, 0}); +} + +// The distance is the MAXIMUM of |dx| and |dy| (a square ring), not the sum or the +// Euclidean length: an off-diagonal light sits on the ring of its larger axis delta. +TEST_CASE("BlockModifier uses max(|dx|,|dy|) so rings are axis-aligned squares") { + mm::BlockModifier b; + // 5x5 box, centre (2,2). Light (4,2): dx=2, dy=0 -> distance 2. + CHECK(fold(b, 4, 2, 0, {5, 5, 1}) == mm::Coord3D{0, 2, 0}); + // Light (4,3): dx=2, dy=1 -> max is 2, same ring as (4,2) — a square edge, not a + // circle (Euclidean would differ, sum-of-deltas would give 3). + CHECK(fold(b, 4, 3, 0, {5, 5, 1}) == mm::Coord3D{0, 2, 0}); + // z is not part of the distance — a light off the plane still folds by x/y only. + CHECK(fold(b, 4, 2, 3, {5, 5, 4}) == mm::Coord3D{0, 2, 0}); +} + +// modifyLogicalSize collapses the box to one column, its height = the max block +// distance + 1 (rings from centre to the far corner, inclusive), depth 1. +TEST_CASE("BlockModifier logical box is {1, maxDistance + 1, 1}") { + mm::BlockModifier b; + // 4x4: centre (1,1), far corner distance max(|4-1|,|4-1|)=3 -> height 3+1=4. + CHECK(blockSize(b, {4, 4, 1}) == mm::Coord3D{1, 4, 1}); + // 3x3: centre (1,1), distance max(|3-1|,|3-1|)=2 -> height 3. + CHECK(blockSize(b, {3, 3, 1}) == mm::Coord3D{1, 3, 1}); + // A wide box takes its larger axis: 8x2 -> centre (3,0), distance max(5,2)=5 -> 6. + CHECK(blockSize(b, {8, 2, 1}) == mm::Coord3D{1, 6, 1}); +} + +// Degenerate grids never crash and stay well-formed: 0x0x0 and 1x1x1 both fold and +// size without dividing by zero or producing a zero-height box (the Effects hard rule). +TEST_CASE("BlockModifier survives degenerate grids") { + mm::BlockModifier b; + // 1x1x1: single light is the centre -> distance 0; logical box {1,1,1}. + CHECK(fold(b, 0, 0, 0, {1, 1, 1}) == mm::Coord3D{0, 0, 0}); + CHECK(blockSize(b, {1, 1, 1}) == mm::Coord3D{1, 1, 1}); + // 0x0x0: no crash; the fold and size are still finite (each axis grows by one). + CHECK_NOTHROW(fold(b, 0, 0, 0, {0, 0, 0})); + CHECK(blockSize(b, {0, 0, 0}).x >= 1); + CHECK(blockSize(b, {0, 0, 0}).y >= 1); + CHECK(blockSize(b, {0, 0, 0}).z >= 1); +} diff --git a/test/unit/light/unit_BlurzEffect.cpp b/test/unit/light/unit_BlurzEffect.cpp new file mode 100644 index 00000000..ffb0f437 --- /dev/null +++ b/test/unit/light/unit_BlurzEffect.cpp @@ -0,0 +1,162 @@ +// @module BlurzEffect +// @also AudioModule + +#include "doctest.h" +#include "light/effects/BlurzEffect.h" +#include "light/layouts/GridLayout.h" +#include "core/AudioModule.h" + +// Blurz is an audio-reactive effect: its dot is coloured by the current band's magnitude and only +// appears when there is a signal. The frame comes from AudioModule::latestFrame() (a process-wide +// static). To feed a signal on the host (no I2S mic) we run a live AudioModule with `simulate` set to +// an "always" mode — synthesizeFrame() then fills the bands each loop(). Every case brackets its own +// AudioModule setup()/teardown() so it never leaks the active-mic pointer into another test file. + +// With no live audio source the buffer stays black: the dot is audio-gated, so silence renders nothing. +TEST_CASE("BlurzEffect stays black without an audio frame") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 8; + grid.height = 8; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::BlurzEffect blurz; + layer.addChild(&blurz); + + layer.onBuildState(); + // No AudioModule is active → latestFrame() is the static all-silence frame (bands all 0). loop() + // does the first-frame clear + fade, then reads silence and returns before drawing any dot. + for (int i = 0; i < 8; i++) layer.loop(); + + auto& buf = layer.buffer(); + REQUIRE(buf.count() == 64); + bool anyLit = false; + for (size_t i = 0; i < buf.bytes(); i++) { + if (buf.data()[i] != 0) { anyLit = true; break; } + } + CHECK_FALSE(anyLit); +} + +// With a synthesized audio frame the effect lights the buffer: the coloured dot appears and the blur +// smears it into a soft blob, so at least some lights become non-zero. +TEST_CASE("BlurzEffect lights the buffer when fed a signal") { + mm::AudioModule audio; + audio.onBuildControls(); + audio.simulate = 3; // music (always): synthesizeFrame() fills the bands every loop, no mic needed + audio.setup(); // claims the active-mic seat so latestFrame() points at this frame + + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 8; + grid.height = 8; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::BlurzEffect blurz; + layer.addChild(&blurz); + + layer.onBuildState(); + + // Advance the audio and the effect together a few frames: the band cursor wraps 0..15, so over + // several ticks the dot lands a non-zero magnitude and paints. + bool anyLit = false; + for (int i = 0; i < 32 && !anyLit; i++) { + audio.loop(); + layer.loop(); + auto& buf = layer.buffer(); + for (size_t j = 0; j < buf.bytes(); j++) { + if (buf.data()[j] != 0) { anyLit = true; break; } + } + } + CHECK(anyLit); + + audio.teardown(); // release the active-mic seat; leave no residue for other tests +} + +// geqScanner sweeps the dot steadily across the strip: one pixel per frame, so consecutive frames land +// the lit dot at different linear positions rather than the same spot. +TEST_CASE("BlurzEffect geqScanner sweeps the dot to a new position each frame") { + mm::AudioModule audio; + audio.onBuildControls(); + audio.simulate = 3; // music (always): keeps the bands non-zero so the dot has colour + audio.setup(); + + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 16; + grid.height = 1; // a strip so the scan position maps directly to the x index + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::BlurzEffect blurz; + blurz.geqScanner = true; // steady sweep instead of a random jump + blurz.blur = 1; // minimal blur so the brightest pixel stays close to the dot core + blurz.fadeRate = 255; // fade the previous frame's trail fully, so the current dot dominates + layer.addChild(&blurz); + + layer.onBuildState(); + + auto brightestIndex = [&]() { + auto& buf = layer.buffer(); + auto* d = buf.data(); + int best = -1, bestSum = -1; + for (size_t p = 0; p < buf.count(); p++) { + int sum = d[p * 3] + d[p * 3 + 1] + d[p * 3 + 2]; + if (sum > bestSum) { bestSum = sum; best = static_cast<int>(p); } + } + return best; + }; + + audio.loop(); layer.loop(); + const int pos1 = brightestIndex(); + audio.loop(); layer.loop(); + const int pos2 = brightestIndex(); + + // The scanner advances the dot one pixel per frame, so the brightest pixel moves between frames. + CHECK(pos1 != pos2); + + audio.teardown(); +} + +// The hard rule: the effect runs at any grid size without crashing, including a 0×0×0 and a 1×1 grid. +TEST_CASE("BlurzEffect survives degenerate grid sizes") { + mm::AudioModule audio; + audio.onBuildControls(); + audio.simulate = 3; + audio.setup(); + + for (auto dims : {mm::Coord3D{0, 0, 0}, mm::Coord3D{1, 1, 1}}) { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = dims.x; + grid.height = dims.y; + grid.depth = dims.z; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::BlurzEffect blurz; + layer.addChild(&blurz); + + layer.onBuildState(); + for (int i = 0; i < 4; i++) { audio.loop(); layer.loop(); } + } + CHECK(true); // no crash at 0×0×0 or 1×1 + + audio.teardown(); +} diff --git a/test/unit/light/unit_BouncingBallsEffect.cpp b/test/unit/light/unit_BouncingBallsEffect.cpp new file mode 100644 index 00000000..07c89cdc --- /dev/null +++ b/test/unit/light/unit_BouncingBallsEffect.cpp @@ -0,0 +1,154 @@ +// @module BouncingBallsEffect + +#include "doctest.h" +#include "light/effects/BouncingBallsEffect.h" +#include "light/layouts/GridLayout.h" +#include "platform/platform.h" // setTestNowMs — freeze millis() for a deterministic first frame + +// Restore the real clock after any test that froze it, so a frozen value can't leak into +// order-dependent neighbours. +namespace { struct ClockGuard { ~ClockGuard() { mm::platform::setTestNowMs(0); } }; } + +// On the first frame every ball is at rest (zero-init state) and bounces off the floor, so the +// effect paints the bottom row of every column and leaves the rows above it black. +TEST_CASE("BouncingBallsEffect lights the bottom row on the first frame") { + ClockGuard guard; + mm::platform::setTestNowMs(1); // any non-zero value freezes millis() at t=1ms + + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 4; + grid.height = 4; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::BouncingBallsEffect balls; + layer.addChild(&balls); + + layer.onBuildState(); + layer.loop(); + + auto& buf = layer.buffer(); + REQUIRE(buf.count() == 16); + + const int w = 4, h = 4; + // Bottom row (y = h-1): at least one column is lit — the balls all rest here on frame one. + bool bottomLit = false; + for (int x = 0; x < w; x++) { + size_t idx = static_cast<size_t>((h - 1) * w + x) * 3; + if (buf.data()[idx] | buf.data()[idx + 1] | buf.data()[idx + 2]) { bottomLit = true; break; } + } + CHECK(bottomLit); + + // Every row above the floor is black on the first frame (balls have not risen yet). + bool upperLit = false; + for (int y = 0; y < h - 1; y++) { + for (int x = 0; x < w; x++) { + size_t idx = static_cast<size_t>(y * w + x) * 3; + if (buf.data()[idx] | buf.data()[idx + 1] | buf.data()[idx + 2]) { upperLit = true; } + } + } + CHECK_FALSE(upperLit); +} + +// numBalls=0 draws nothing: the effect clamps below its minimum and the buffer stays black. +TEST_CASE("BouncingBallsEffect with zero balls leaves the buffer black") { + ClockGuard guard; + mm::platform::setTestNowMs(1); + + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 4; + grid.height = 4; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::BouncingBallsEffect balls; + balls.numBalls = 0; // nBalls <= 0 → loop() returns before drawing + layer.addChild(&balls); + + layer.onBuildState(); + layer.loop(); + + auto& buf = layer.buffer(); + bool anyLit = false; + for (size_t i = 0; i < buf.bytes(); i++) { + if (buf.data()[i] != 0) { anyLit = true; break; } + } + CHECK_FALSE(anyLit); +} + +// The effect runs at a degenerate grid size without crashing (the "every grid size" hard rule). +TEST_CASE("BouncingBallsEffect survives a 0x0x0 grid") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 0; + grid.height = 0; + grid.depth = 0; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::BouncingBallsEffect balls; + layer.addChild(&balls); + + // No allocation, no draw, no crash on a zero grid. + layer.onBuildState(); + layer.loop(); + + CHECK(layer.buffer().count() == 0); +} + +// A ball reaches its apex mid-flight: once time advances, at least one ball has risen off the +// bottom row, so the lit column occupies a row above the floor. +TEST_CASE("BouncingBallsEffect balls rise above the floor as time advances") { + ClockGuard guard; + + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 1; + grid.height = 16; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::BouncingBallsEffect balls; + balls.numBalls = 8; + layer.addChild(&balls); + + layer.onBuildState(); + + // Frame one at t=1 relaunches every ball (impactVelocity kick set from its floor bounce). + mm::platform::setTestNowMs(1); + layer.loop(); + + // A ball with the maximum kick (√19.62·1.0 ≈ 4.43) climbs for ~450ms before falling back; sample + // partway up its arc. Scan a spread of instants so the assertion doesn't hinge on one exact frame. + const int w = 1, h = 16; + bool roseAboveFloor = false; + for (uint32_t t = 50; t <= 400 && !roseAboveFloor; t += 50) { + mm::platform::setTestNowMs(1 + t); + layer.loop(); + for (int y = 0; y < h - 1; y++) { // any row strictly above the floor + size_t idx = static_cast<size_t>(y * w + 0) * 3; + if (layer.buffer().data()[idx] | layer.buffer().data()[idx + 1] | layer.buffer().data()[idx + 2]) { + roseAboveFloor = true; + break; + } + } + } + CHECK(roseAboveFloor); +} diff --git a/test/unit/light/unit_CircleModifier.cpp b/test/unit/light/unit_CircleModifier.cpp new file mode 100644 index 00000000..ed8555c1 --- /dev/null +++ b/test/unit/light/unit_CircleModifier.cpp @@ -0,0 +1,72 @@ +// @module CircleModifier + +#include "doctest.h" +#include "light/modifiers/CircleModifier.h" + +// CircleModifier folds every physical light to its Euclidean distance from the box +// centre: a coord becomes (0, distance, 0), so a 2D box collapses to a single column +// of concentric rings. modifyLogicalSize runs the box's own far corner through that +// same fold and then grows every axis by one. Integer centre offsets (box/2), float +// distance truncated back to lengthType — MoonLight's exact geometry. + +// Fold a physical coord through a Circle built on `box`; returns the folded coord. +static mm::Coord3D fold(mm::CircleModifier& c, mm::lengthType x, mm::lengthType y, + mm::lengthType z, mm::Coord3D box) { + c.modifyLogicalSize(box); // stashes the box so the fold reads the centre + mm::Coord3D p{x, y, z}; + c.modifyLogical(p); + return p; +} + +// The logical size for a given physical box. +static mm::Coord3D circleSize(mm::CircleModifier& c, mm::Coord3D box) { + c.modifyLogicalSize(box); + return box; +} + +// The box centre folds to the origin (distance 0), and every coord collapses onto the +// single x=0/z=0 column — the box becomes a 1D radial run. +TEST_CASE("CircleModifier folds the centre to the origin and collapses to one column") { + mm::CircleModifier c; + const mm::Coord3D box{8, 8, 1}; // centre is (4, 4, 0) + + CHECK(fold(c, 4, 4, 0, box) == mm::Coord3D{0, 0, 0}); // centre → radius 0 + // Every folded coord lives on x=0, z=0 whatever it was. + for (mm::lengthType y = 0; y < 8; y++) + for (mm::lengthType x = 0; x < 8; x++) { + const mm::Coord3D p = fold(c, x, y, 0, box); + CHECK(p.x == 0); + CHECK(p.z == 0); + } +} + +// A light's ring is its integer-truncated Euclidean distance from the centre, so two +// lights equidistant from the centre map to the SAME radius — the defining circle +// property. Centre (4,4): (7,4) and (4,7) are both 3 away; (7,7) is sqrt(18)→4. +TEST_CASE("CircleModifier maps equidistant lights to the same ring") { + mm::CircleModifier c; + const mm::Coord3D box{8, 8, 1}; // centre (4, 4, 0) + + CHECK(fold(c, 7, 4, 0, box) == mm::Coord3D{0, 3, 0}); // dx=3 → radius 3 + CHECK(fold(c, 4, 7, 0, box) == mm::Coord3D{0, 3, 0}); // dy=3 → radius 3 (same ring) + CHECK(fold(c, 7, 7, 0, box) == mm::Coord3D{0, 4, 0}); // sqrt(9+9)=4.24 → truncated 4 + CHECK(fold(c, 4, 4, 0, box) == mm::Coord3D{0, 0, 0}); // centre → radius 0 +} + +// modifyLogicalSize folds the far corner to its distance, then grows every axis by one: +// the logical box is a (radius+1)-tall column with x=1 and z=1. Corner (8,8,1) off +// centre (4,4,0) is sqrt(16+16+1)=5.74→5, so the size is (0+1, 5+1, 0+1). +TEST_CASE("CircleModifier logical size is a radius-tall single column") { + mm::CircleModifier c; + CHECK(circleSize(c, {8, 8, 1}) == mm::Coord3D{1, 6, 1}); +} + +// Effects-run-at-every-grid-size hard rule: a degenerate box never crashes and still +// yields a valid single-column size. 0×0×0 folds to (0,0,0)→+1 = (1,1,1); 1×1×1's +// corner is sqrt(3)→1, so size (1, 2, 1). +TEST_CASE("CircleModifier survives degenerate grids") { + mm::CircleModifier c; + CHECK(circleSize(c, {0, 0, 0}) == mm::Coord3D{1, 1, 1}); + CHECK(circleSize(c, {1, 1, 1}) == mm::Coord3D{1, 2, 1}); + CHECK(fold(c, 0, 0, 0, {1, 1, 1}) == mm::Coord3D{0, 0, 0}); // sole light, no crash +} diff --git a/test/unit/light/unit_FixedRectangleEffect.cpp b/test/unit/light/unit_FixedRectangleEffect.cpp new file mode 100644 index 00000000..05171467 --- /dev/null +++ b/test/unit/light/unit_FixedRectangleEffect.cpp @@ -0,0 +1,141 @@ +// @module FixedRectangleEffect + +#include "doctest.h" +#include "light/effects/FixedRectangleEffect.h" +#include "light/layouts/GridLayout.h" + +#include <utility> // std::pair for the outside-the-box coordinate list + +// A small box (2×2 at the origin) lights exactly its cells and leaves every cell outside it black. +TEST_CASE("FixedRectangleEffect lights only cells inside the configured rect") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 4; + grid.height = 4; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::FixedRectangleEffect rect; + rect.rectX = 0; rect.rectY = 0; rect.rectZ = 0; + rect.rectW = 2; rect.rectH = 2; rect.rectD = 1; + rect.red = 182; rect.green = 15; rect.blue = 98; + layer.addChild(&rect); + + layer.onBuildState(); + layer.loop(); + + auto* data = layer.buffer().data(); + REQUIRE(layer.buffer().count() == 16); + + // Inside the 2×2 box: each cell carries the configured RGB. + for (int y = 0; y < 2; y++) { + for (int x = 0; x < 2; x++) { + size_t off = static_cast<size_t>(y * 4 + x) * 3; + CHECK(data[off + 0] == 182); + CHECK(data[off + 1] == 15); + CHECK(data[off + 2] == 98); + } + } + + // Outside the box: cells stay black (e.g. (2,0), (0,2), (3,3)). + for (auto [x, y] : {std::pair{2, 0}, std::pair{3, 0}, std::pair{0, 2}, std::pair{3, 3}}) { + size_t off = static_cast<size_t>(y * 4 + x) * 3; + CHECK(data[off + 0] == 0); + CHECK(data[off + 1] == 0); + CHECK(data[off + 2] == 0); + } +} + +// With defaults (origin 0,0,0 + 15×15×15 extent) the box fills the whole grid — the origin corner lights up. +TEST_CASE("FixedRectangleEffect defaults light the origin corner and fill a small grid") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 4; + grid.height = 4; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::FixedRectangleEffect rect; // defaults: 15×15×15 box at (0,0,0) + layer.addChild(&rect); + + layer.onBuildState(); + layer.loop(); + + auto* data = layer.buffer().data(); + + // Origin corner (0,0) is lit with the default colour {182,15,98}. + CHECK(data[0] == 182); + CHECK(data[1] == 15); + CHECK(data[2] == 98); + + // The 15×15 default clamps to the 4×4 grid, so every cell is lit (none black). + bool allLit = true; + for (size_t i = 0; i < layer.buffer().count(); i++) { + size_t off = i * 3; + if (data[off] == 0 && data[off + 1] == 0 && data[off + 2] == 0) { allLit = false; break; } + } + CHECK(allLit); +} + +// The box is offset away from the origin: only the offset cell lights, the origin stays black. +TEST_CASE("FixedRectangleEffect honours a non-zero origin") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 4; + grid.height = 4; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::FixedRectangleEffect rect; + rect.rectX = 2; rect.rectY = 1; rect.rectZ = 0; + rect.rectW = 1; rect.rectH = 1; rect.rectD = 1; + layer.addChild(&rect); + + layer.onBuildState(); + layer.loop(); + + auto* data = layer.buffer().data(); + + // Cell (2,1) is lit. + size_t litOff = static_cast<size_t>(1 * 4 + 2) * 3; + CHECK((data[litOff] > 0 || data[litOff + 1] > 0 || data[litOff + 2] > 0)); + + // Origin (0,0) is untouched — black. + CHECK(data[0] == 0); + CHECK(data[1] == 0); + CHECK(data[2] == 0); +} + +// A degenerate 0×0×0 grid must not crash (Effects run at every grid size). +TEST_CASE("FixedRectangleEffect survives a degenerate grid") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 0; + grid.height = 0; + grid.depth = 0; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::FixedRectangleEffect rect; + layer.addChild(&rect); + + layer.onBuildState(); + layer.loop(); // must return without dereferencing an empty buffer + + CHECK(true); +} diff --git a/test/unit/light/unit_FreqMatrixEffect.cpp b/test/unit/light/unit_FreqMatrixEffect.cpp new file mode 100644 index 00000000..4dfbe4af --- /dev/null +++ b/test/unit/light/unit_FreqMatrixEffect.cpp @@ -0,0 +1,158 @@ +// @module FreqMatrixEffect +// @also AudioModule + +#include "doctest.h" +#include "light/effects/FreqMatrixEffect.h" +#include "light/layouts/GridLayout.h" +#include "core/AudioModule.h" +#include "platform/platform.h" // setTestNowMs — deterministic virtual time + +// FreqMatrixEffect is audio-driven: it paints the new x=0/y=0 pixel from +// AudioModule::latestFrame() (hue from peakHz, brightness from levelSmoothed) and +// scrolls the column away from y=0 each tick. To pin real behaviour the frame is +// fed through a live AudioModule in simulate=4 (sweep, always): on desktop +// (hasI2sMic=false) loop() runs synthesizeFrame, elects the module as the active +// mic, and fills a DETERMINISTIC frame off platform::millis(). At the frozen time +// t=375 the sweep is at step pos=1 (peakHz = 80 + 1*700 = 780 Hz, comfortably +// above the effect's 80 Hz tone gate) and env=triwave8(127)=254 (a near-full +// level). Driving the mic's loop() repeatedly at that time converges the smoothed +// level (a /4 EMA of 254) well past the effect's `levelSmoothed > 64` gate, so the +// painted pixel is guaranteed lit — letting us assert the paint and the scroll, +// not just "renders non-zero". + +namespace { + +// Owns the Layouts/GridLayout/Layer wiring every case shares: build a grid of the +// given dimensions, parent it, and configure the layer's channels. Same struct-Ctx +// idiom as unit_effects_render.cpp; each case adds its FreqMatrixEffect afterward. +struct GridLayer { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + + GridLayer(mm::lengthType w, mm::lengthType h, mm::lengthType d, uint8_t channels) { + grid.width = w; + grid.height = h; + grid.depth = d; + layouts.addChild(&grid); + layer.setLayouts(&layouts); + layer.setChannelsPerLight(channels); + } +}; + +// Restores real-clock behaviour and vacates the process-wide active-mic seat so +// each case is independent (both are global state a prior case could leave set). +struct AudioGuard { + mm::AudioModule& mic; + ~AudioGuard() { + mic.teardown(); // clears AudioModule::active_ if it is this mic + mm::platform::setTestNowMs(0); // restore the platform clock + } +}; + +// Frozen time at which the sweep sits on band 1 with a near-full level. +constexpr uint32_t kToneMs = 375; + +// Converge the mic's smoothed level to a lit value at the frozen time, then return +// it as the (now) active source. Several ticks let the /4 EMA climb past 254*3/4… +// so f->levelSmoothed clears the effect's >64 gate deterministically. +void driveLoudTone(mm::AudioModule& mic) { + mic.simulate = 4; // sweep, always — deterministic single-band sweep + mm::platform::setTestNowMs(kToneMs); + for (int i = 0; i < 20; i++) mic.loop(); // EMA converges toward level 254 + const mm::AudioFrame* f = mm::AudioModule::latestFrame(); + REQUIRE(f->peakHz > 80); // a real tone, above the effect's gate + REQUIRE(f->levelSmoothed > 64); // above the effect's brightness/colour gate +} + +} // namespace + +// A real tone above the 80 Hz gate paints a lit colour at the source pixel (0,0). +TEST_CASE("FreqMatrixEffect paints a lit source pixel from a live tone") { + mm::AudioModule mic; + AudioGuard guard{mic}; + driveLoudTone(mic); + + GridLayer g(1, 8, 1, 3); + + mm::FreqMatrixEffect fx; + fx.speed = 255; // period = 256 - 255 = 1 ms → scrolls on the first tick + g.layer.addChild(&fx); + g.layer.onBuildState(); + + g.layer.loop(); // paints the new pixel at y=0 (elapsed = kToneMs, throttle passes) + + auto* data = g.layer.buffer().data(); + // Pixel (0,0) is index 0: the source end carries the freshly-painted lit colour. + CHECK((data[0] > 0 || data[1] > 0 || data[2] > 0)); +} + +// The column is a shift register: a lit source pixel scrolls to y=1 on the next tick. +TEST_CASE("FreqMatrixEffect scrolls the painted pixel one step along Y") { + mm::AudioModule mic; + AudioGuard guard{mic}; + driveLoudTone(mic); + + GridLayer g(1, 8, 1, 3); + + mm::FreqMatrixEffect fx; + fx.speed = 255; // period 1 ms → any millis advance re-scrolls + g.layer.addChild(&fx); + g.layer.onBuildState(); + + g.layer.loop(); // tick 1 at kToneMs: paints the lit colour at y=0 + auto* data = g.layer.buffer().data(); + const uint8_t r0 = data[0], g0 = data[1], b0 = data[2]; + REQUIRE((r0 > 0 || g0 > 0 || b0 > 0)); // something lit landed at the source + + // Advance the clock so the throttle passes again and the sweep still lands a + // lit band (pos = (t/250)%16 = 1 at t=380, env still high) — the column shifts. + mm::platform::setTestNowMs(kToneMs + 5); + mic.loop(); // refresh the frame at the new time (still a loud tone on band 1) + g.layer.loop(); // tick 2: the y=0 colour of tick 1 moves up to y=1 + + data = g.layer.buffer().data(); // buffer may have been rebuilt; re-read + // Pixel (0,1) is index 1*3 = 3: it now carries the colour painted at y=0 on tick 1. + CHECK(data[3] == r0); + CHECK(data[4] == g0); + CHECK(data[5] == b0); +} + +// Silence (no active mic → the static silent frame) paints black: no tone, no light. +TEST_CASE("FreqMatrixEffect paints black on silence") { + // Ensure no mic holds the active seat, so latestFrame() is the all-zero frame + // (peakHz 0, levelSmoothed 0) — both below the effect's gates. + { mm::AudioModule idle; idle.teardown(); } + mm::platform::setTestNowMs(1000); + + GridLayer g(1, 8, 1, 3); + + mm::FreqMatrixEffect fx; + fx.speed = 255; + g.layer.addChild(&fx); + g.layer.onBuildState(); + + REQUIRE(mm::AudioModule::latestFrame()->peakHz == 0); // silence: no tone + g.layer.loop(); + + auto* data = g.layer.buffer().data(); + // The freshly-painted source pixel is black — silence scrolls dark. + CHECK(data[0] == 0); + CHECK(data[1] == 0); + CHECK(data[2] == 0); + + mm::platform::setTestNowMs(0); +} + +// The "runs at every grid size" hard rule: degenerate grids never crash. +TEST_CASE("FreqMatrixEffect survives degenerate grid sizes") { + for (auto dims : {mm::Coord3D{0, 0, 0}, mm::Coord3D{1, 1, 1}}) { + GridLayer g(dims.x, dims.y, dims.z, 3); + + mm::FreqMatrixEffect fx; + g.layer.addChild(&fx); + g.layer.onBuildState(); + g.layer.loop(); // must not crash on 0×0×0 or 1×1×1 + } + CHECK(true); +} diff --git a/test/unit/light/unit_FreqSawsEffect.cpp b/test/unit/light/unit_FreqSawsEffect.cpp new file mode 100644 index 00000000..e491d909 --- /dev/null +++ b/test/unit/light/unit_FreqSawsEffect.cpp @@ -0,0 +1,125 @@ +// @module FreqSawsEffect +// @also AudioModule + +#include "doctest.h" +#include "light/effects/FreqSawsEffect.h" +#include "light/layouts/GridLayout.h" +#include "core/AudioModule.h" + +// Helper: any non-zero byte in the layer buffer (a lit pixel somewhere). +static bool anyLit(mm::Layer& layer) { + auto& buf = layer.buffer(); + for (size_t i = 0; i < buf.bytes(); i++) + if (buf.data()[i] != 0) return true; + return false; +} + +// With no audio (silence) and keepOn off, every band decays to rest so the effect draws nothing — +// the whole buffer stays black. (No mic is active here, so latestFrame() is the static silence.) +TEST_CASE("FreqSawsEffect silence with keepOn off leaves the buffer dark") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 16; grid.height = 16; grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::FreqSawsEffect saws; + saws.keepOn = false; // a decayed band draws nothing + layer.addChild(&saws); + + layer.onBuildState(); + layer.loop(); + + REQUIRE(layer.buffer().data() != nullptr); + CHECK_FALSE(anyLit(layer)); // silence in, dark out +} + +// keepOn keeps every band drawing even when its speed has fully decayed, so on a rested (silent) +// panel the columns are still lit rather than fully dark between hits. +TEST_CASE("FreqSawsEffect keepOn draws bands even with no audio") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 16; grid.height = 16; grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + mm::Palettes::setActive(0); // colourful palette so a drawn pixel is non-black (order-independent) + + mm::FreqSawsEffect saws; + saws.keepOn = true; // draw a band whose speed has decayed to zero + saws.fade = 0; // no fade so a just-drawn pixel survives the frame + layer.addChild(&saws); + + layer.onBuildState(); + layer.loop(); + + CHECK(anyLit(layer)); // keepOn lights the panel with no audio at all +} + +// Fed a live (simulated) audio frame, the effect reacts: loud bands rise and paint their columns, +// leaving the buffer non-black even with keepOn off (so the light comes from the audio, not keepOn). +TEST_CASE("FreqSawsEffect reacts to a fed audio frame") { + // Claim the active-mic seat with a module synthesizing loud "music" every tick (desktop has no + // I2S mic, so simulate is the only source; music-always fills every band). latestFrame() then + // hands this module's frame to the effect. + mm::AudioModule mic; + mic.onBuildControls(); + mic.simulate = 3; // 3 = music (always): synthesize regardless of a real mic + mic.setup(); // registers as the active mic + + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 16; grid.height = 16; grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + mm::Palettes::setActive(0); + + mm::FreqSawsEffect saws; + saws.keepOn = false; // any light MUST come from the audio, not keepOn + saws.fade = 0; + layer.addChild(&saws); + layer.onBuildState(); + + // A few ticks: each refreshes the synthesized loud frame, then the effect renders it. Loud bands + // rise instantly (max with target), so a lit column appears within these frames. + bool lit = false; + for (int tick = 0; tick < 8 && !lit; tick++) { + mic.loop(); // (re)synthesize a loud music frame into the active mic + layer.loop(); // effect reads latestFrame() and draws the active bands + lit = anyLit(layer); + } + CHECK(lit); + + mic.teardown(); // vacate the seat so later tests read silence again +} + +// The "runs at every grid size" hard rule: a degenerate 0×0×0 grid and a 1×1 grid both render +// without crashing (the imap zero-span guard and the sizeX/sizeY<=0 early-out cover them). +TEST_CASE("FreqSawsEffect runs at degenerate grid sizes without crashing") { + for (auto dims : {mm::Coord3D{0, 0, 0}, mm::Coord3D{1, 1, 1}}) { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = dims.x; grid.height = dims.y; grid.depth = dims.z; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::FreqSawsEffect saws; + saws.keepOn = true; // exercise the draw path too, not just the early-out + layer.addChild(&saws); + + layer.onBuildState(); + layer.loop(); + CHECK(true); // reaching here without a crash is the assertion + } +} diff --git a/test/unit/light/unit_GEQ3DEffect.cpp b/test/unit/light/unit_GEQ3DEffect.cpp new file mode 100644 index 00000000..e6b5b87a --- /dev/null +++ b/test/unit/light/unit_GEQ3DEffect.cpp @@ -0,0 +1,157 @@ +// @module GEQ3DEffect +// @also AudioModule + +#include "doctest.h" +#include "light/effects/GEQ3DEffect.h" +#include "light/layouts/GridLayout.h" +#include "core/AudioModule.h" +#include "platform/platform.h" + +// GEQ3D is audio-driven: it renders 16 bars from AudioModule::latestFrame()->bands. +// These cases pin its behaviour deterministically by freezing the clock +// (platform::setTestNowMs) and driving a synthesized frame through an active +// AudioModule in "sweep (always)" simulate mode. Restore the real clock and vacate +// the process-wide active mic on teardown so cases stay order-independent. +struct ClockGuard { ~ClockGuard() { platform::setTestNowMs(0); } }; + +// Vacates the process-wide active-mic seat on scope exit (even if an assertion aborts +// the case), so a failed REQUIRE can't leak AudioModule::active_ into a later test. +struct AudioGuard { + mm::AudioModule& mic; + ~AudioGuard() { mic.teardown(); } // clears AudioModule::active_ if it is this mic +}; + +// Silence (no active mic) leaves the buffer all-black — every band magnitude is 0, so no bar rises. +TEST_CASE("GEQ3DEffect renders black on silence") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 16; + grid.height = 16; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::GEQ3DEffect geq; + layer.addChild(&geq); + + layer.onBuildState(); + // No AudioModule is active, so latestFrame() returns static silence (all bands 0). + layer.loop(); + + auto& buf = layer.buffer(); + REQUIRE(buf.data() != nullptr); + REQUIRE(buf.count() == 256); + for (size_t i = 0; i < buf.bytes(); i++) CHECK(buf.data()[i] == 0); +} + +// A synthesized sweep frame with only the lowest band lit paints the LEFT of the grid and leaves the far RIGHT dark. +TEST_CASE("GEQ3DEffect draws a bar where the audio band is energised") { + ClockGuard guard; + // Sweep mode: pos = (t/250)%16, env = triwave8((t%250)*255/250). At t=125 → pos 0, env ≈ 254, + // so only bands[0] is high — the leftmost bar rises, the rest stay flat. + platform::setTestNowMs(125); + + mm::AudioModule mic; + AudioGuard micGuard{mic}; // vacate the active mic on scope exit (even if a REQUIRE aborts) + mic.simulate = 4; // "sweep (always)" — deterministic single-band test pattern + mic.setup(); // claims the process-wide active mic seat + mic.loop(); // synthesizes the frame at the frozen time + REQUIRE(mm::AudioModule::latestFrame()->bands[0] > 1); + + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 16; + grid.height = 16; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::GEQ3DEffect geq; + layer.addChild(&geq); + + layer.onBuildState(); + layer.loop(); + + auto* data = layer.buffer().data(); + const int w = 16, h = 16; + auto lit = [&](int x, int y) { + size_t idx = (static_cast<size_t>(y) * w + x) * 3; + return data[idx] || data[idx + 1] || data[idx + 2]; + }; + + // Band 0 → leftmost bar (one 16/16 = 1-wide column at x=0), a tall front-fill up the left edge. + bool leftLit = false; + for (int y = 0; y < h; y++) leftLit |= lit(0, y); + CHECK(leftLit); + + // The far-right column carries no bar (only band 0 is energised). + bool rightLit = false; + for (int y = 0; y < h; y++) rightLit |= lit(w - 1, y); + CHECK_FALSE(rightLit); +} + +// The effect runs at a degenerate 0×0×0 grid without crashing (the "every grid size" hard rule). +TEST_CASE("GEQ3DEffect survives a zero-size grid") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 0; + grid.height = 0; + grid.depth = 0; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::GEQ3DEffect geq; + layer.addChild(&geq); + + layer.onBuildState(); + layer.loop(); // must not crash on a 0×0×0 grid + CHECK(true); +} + +// A narrow grid with fewer columns than bands still spreads bars (numBands is clamped to the column count, so no divide-by-zero, no bar pile-up at x=0) and never crashes. +TEST_CASE("GEQ3DEffect handles a grid narrower than numBands") { + ClockGuard guard; + platform::setTestNowMs(125); // sweep → bands[0] high + + mm::AudioModule mic; + AudioGuard micGuard{mic}; // vacate the active mic on scope exit (even if a REQUIRE aborts) + mic.simulate = 4; + mic.setup(); + mic.loop(); + + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 4; // fewer columns than the 16 bands + grid.height = 8; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::GEQ3DEffect geq; + geq.numBands = 16; + layer.addChild(&geq); + + layer.onBuildState(); + layer.loop(); // clamps bands to 4 cols → bar width ≥ 1, no crash + + // Band 0 maps to the leftmost column of a 4-wide grid; something is lit there. + auto* data = layer.buffer().data(); + bool leftLit = false; + for (int y = 0; y < 8; y++) { + size_t idx = (static_cast<size_t>(y) * 4 + 0) * 3; + leftLit |= data[idx] || data[idx + 1] || data[idx + 2]; + } + CHECK(leftLit); +} diff --git a/test/unit/light/unit_GEQEffect.cpp b/test/unit/light/unit_GEQEffect.cpp new file mode 100644 index 00000000..842732c0 --- /dev/null +++ b/test/unit/light/unit_GEQEffect.cpp @@ -0,0 +1,190 @@ +// @module GEQEffect +// @also AudioModule + +#include "doctest.h" +#include "light/effects/GEQEffect.h" +#include "light/layouts/GridLayout.h" +#include "core/AudioModule.h" + +#include <array> + +// GEQ is an audio-reactive 2D effect: the 16 bands spread across the columns and each column rises as a +// bar from the floor (bottom row) up to a height set by its band's loudness. The frame comes from +// AudioModule::latestFrame() (a process-wide static). To feed a signal on the host (no I2S mic) we run a +// live AudioModule with `simulate` set to an "always" mode — synthesizeFrame() fills the bands each +// loop(). Every case that needs audio brackets its own AudioModule setup()/teardown() so it never leaks +// the active-mic pointer into another test file. Buffer index = (y*width + x)*3, y=0 is the TOP row so +// y=height-1 is the floor the bars grow up from. + +// With no live audio source every band is silent, so no bar rises and the buffer stays black. +TEST_CASE("GEQEffect stays black without an audio frame") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 8; + grid.height = 8; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::GEQEffect geq; + geq.ripple = 0; // no falling peak dot: silence must leave the buffer fully dark + layer.addChild(&geq); + + layer.onBuildState(); + // No AudioModule is active → latestFrame() is the static all-silence frame (bands all 0). Each loop + // fades then reads silence → every bar height 0 → nothing drawn. + for (int i = 0; i < 8; i++) layer.loop(); + + auto& buf = layer.buffer(); + REQUIRE(buf.count() == 64); + bool anyLit = false; + for (size_t i = 0; i < buf.bytes(); i++) { + if (buf.data()[i] != 0) { anyLit = true; break; } + } + CHECK_FALSE(anyLit); +} + +// A bar grows from the floor up: when a column's band is loud, its bottom (floor) pixel is lit while a +// pixel above the bar's top stays dark — bars fill upward from the bottom row, not top-down or floating. +TEST_CASE("GEQEffect fills columns from the floor upward") { + mm::AudioModule audio; + audio.onBuildControls(); + audio.simulate = 4; // sweep (always): one band lit at a time, deterministic — column 0 maps to + // band 0 (bass), so we can drive a known column loud. + audio.setup(); + + const int W = 16, H = 8; + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = W; + grid.height = H; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::GEQEffect geq; + geq.ripple = 0; // disable the peak dot so the only lit pixels are the bar itself + geq.fadeOut = 255; // fully clear the previous frame so a lit floor pixel is this frame's bar + layer.addChild(&geq); + + layer.onBuildState(); + + auto floorLit = [&](int x) { + auto* d = layer.buffer().data(); + size_t idx = (static_cast<size_t>(H - 1) * W + x) * 3; // bottom row of column x + return d[idx] || d[idx + 1] || d[idx + 2]; + }; + auto topLit = [&](int x) { + auto* d = layer.buffer().data(); + size_t idx = (static_cast<size_t>(0) * W + x) * 3; // top row of column x + return d[idx] || d[idx + 1] || d[idx + 2]; + }; + + // Sweep steps a lit band every ~250 ms; run frames until some column's floor lights, then assert + // the invariant on that column: if the floor is dark, the top must be dark too (a bar never floats). + bool sawBar = false; + for (int i = 0; i < 64; i++) { + audio.loop(); + layer.loop(); + for (int x = 0; x < W; x++) { + if (floorLit(x)) sawBar = true; + // A lit top pixel with a dark floor would mean the bar didn't grow from the bottom. + if (topLit(x)) CHECK(floorLit(x)); + } + } + CHECK(sawBar); // at least one column rose during the sweep + + audio.teardown(); +} + +// colorBars colours each bar by its column index, so two well-separated lit columns take different hues +// rather than sharing the row-height gradient — the toggle changes what colour a bar is. +TEST_CASE("GEQEffect colorBars colours bars per column") { + mm::AudioModule audio; + audio.onBuildControls(); + audio.simulate = 3; // music (always): keeps every band non-zero so many columns rise together + audio.setup(); + + const int W = 16, H = 8; + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = W; + grid.height = H; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::GEQEffect geq; + geq.colorBars = true; // hue per column: imap(x,0,W-1,0,255) + geq.ripple = 0; + geq.fadeOut = 255; + layer.addChild(&geq); + + layer.onBuildState(); + mm::Palettes::setActive(0); // Rainbow: index maps to a spread of hues, order-independent + + // Advance until both an early and a late column have a lit floor, then compare their colours. + const int xa = 0, xb = W - 1; + auto color = [&](int x) { + auto* d = layer.buffer().data(); + size_t idx = (static_cast<size_t>(H - 1) * W + x) * 3; + return std::array<uint8_t, 3>{d[idx], d[idx + 1], d[idx + 2]}; + }; + auto lit = [](const std::array<uint8_t, 3>& c) { return c[0] || c[1] || c[2]; }; + + bool compared = false; + for (int i = 0; i < 64 && !compared; i++) { + audio.loop(); + layer.loop(); + auto ca = color(xa), cb = color(xb); + if (lit(ca) && lit(cb)) { + // Column 0 (hue 0) and column 15 (hue 255) are opposite ends of the palette: their bar + // colours differ, confirming the colour is driven by column, not by shared row height. + CHECK((ca[0] != cb[0] || ca[1] != cb[1] || ca[2] != cb[2])); + compared = true; + } + } + CHECK(compared); + + audio.teardown(); +} + +// The hard rule: the effect runs at any grid size without crashing, including 0×0×0 and 1×1, with a live +// audio frame feeding it every tick. +TEST_CASE("GEQEffect survives degenerate grid sizes") { + mm::AudioModule audio; + audio.onBuildControls(); + audio.simulate = 3; + audio.setup(); + + for (auto dims : {mm::Coord3D{0, 0, 0}, mm::Coord3D{1, 1, 1}}) { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = dims.x; + grid.height = dims.y; + grid.depth = dims.z; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::GEQEffect geq; + layer.addChild(&geq); + + layer.onBuildState(); + for (int i = 0; i < 4; i++) { audio.loop(); layer.loop(); } + } + CHECK(true); // no crash at 0×0×0 or 1×1 + + audio.teardown(); +} diff --git a/test/unit/light/unit_LissajousEffect.cpp b/test/unit/light/unit_LissajousEffect.cpp new file mode 100644 index 00000000..426acf4f --- /dev/null +++ b/test/unit/light/unit_LissajousEffect.cpp @@ -0,0 +1,124 @@ +// @module LissajousEffect + +#include "doctest.h" +#include "light/effects/LissajousEffect.h" +#include "light/layouts/GridLayout.h" + +// A single frame paints part of the grid: the swept curve lights some pixels (not a black frame). +TEST_CASE("LissajousEffect traces a lit curve on the grid") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 16; + grid.height = 16; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::LissajousEffect lissajous; + layer.addChild(&lissajous); + + layer.onBuildState(); + // Pin a colourful palette (Rainbow=0) so painted pixels are non-black regardless of prior tests + // mutating the process-wide active palette. + mm::Palettes::setActive(0); + layer.loop(); + + auto& buf = layer.buffer(); + REQUIRE(buf.data() != nullptr); + REQUIRE(buf.count() == 256); + + // The curve samples 256 points across a 16×16 grid, so at least one pixel is lit. + bool hasNonZero = false; + for (size_t i = 0; i < buf.bytes(); i++) { + if (buf.data()[i] != 0) { hasNonZero = true; break; } + } + CHECK(hasNonZero); +} + +// The curve is sparse: on a large grid it lights only some pixels, leaving others black. +TEST_CASE("LissajousEffect leaves untouched pixels black") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 32; + grid.height = 32; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::LissajousEffect lissajous; + layer.addChild(&lissajous); + + layer.onBuildState(); + mm::Palettes::setActive(0); + layer.loop(); + + auto& buf = layer.buffer(); + REQUIRE(buf.count() == 1024); + + // A single 256-sample sweep cannot cover 1024 lights, so at least one pixel stays black — + // this distinguishes a traced curve from a full-buffer fill. + bool hasBlack = false; + for (mm::nrOfLightsType px = 0; px < buf.count(); px++) { + size_t idx = static_cast<size_t>(px) * 3; + if (buf.data()[idx] == 0 && buf.data()[idx + 1] == 0 && buf.data()[idx + 2] == 0) { + hasBlack = true; + break; + } + } + CHECK(hasBlack); +} + +// On a 1×1 grid the whole curve collapses onto the single origin light without indexing out of bounds. +TEST_CASE("LissajousEffect on a 1x1 grid maps the curve to the origin light") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 1; + grid.height = 1; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::LissajousEffect lissajous; + layer.addChild(&lissajous); + + layer.onBuildState(); + mm::Palettes::setActive(0); + layer.loop(); + + auto& buf = layer.buffer(); + REQUIRE(buf.count() == 1); + // A size-1 axis maps to coordinate 0, so every sample lands on light 0: it must be lit. + CHECK((buf.data()[0] > 0 || buf.data()[1] > 0 || buf.data()[2] > 0)); +} + +// Effects must run at every grid size (hard rule): a 0×0×0 grid renders without crashing. +TEST_CASE("LissajousEffect on a 0x0x0 grid does not crash") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 0; + grid.height = 0; + grid.depth = 0; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::LissajousEffect lissajous; + layer.addChild(&lissajous); + + layer.onBuildState(); + // Degenerate grid: loop() bails on width/height <= 0, so this is a safe no-op frame. + layer.loop(); + + CHECK(layer.buffer().count() == 0); +} diff --git a/test/unit/light/unit_MirrorModifier.cpp b/test/unit/light/unit_MirrorModifier.cpp new file mode 100644 index 00000000..4ece7edc --- /dev/null +++ b/test/unit/light/unit_MirrorModifier.cpp @@ -0,0 +1,86 @@ +// @module MirrorModifier + +#include "doctest.h" +#include "light/modifiers/MirrorModifier.h" + +// MirrorModifier folds the far half of the logical box back onto the near half per +// axis. modifyLogicalSize halves each mirrored axis (rounding up: ceil(size/2) = +// (size+1)/2, so an odd extent keeps its unpaired centre column); modifyLogical then +// reflects any coordinate at or past the half-extent back via `half*2 - 1 - pos`, so a +// far-half physical light and its near-half mirror share one logical source. + +// The mirrored (halved) box size for a given physical box. +static mm::Coord3D mirrorSize(mm::MirrorModifier& m, mm::Coord3D box) { + m.modifyLogicalSize(box); + return box; +} + +// Fold a physical coord to its logical source. `box` is the physical box, run through +// modifyLogicalSize first so the modifier stashes its halved box for the const fold. +static mm::Coord3D fold(mm::MirrorModifier& m, mm::lengthType x, mm::lengthType y, + mm::lengthType z, mm::Coord3D box) { + mm::Coord3D logical = box; + m.modifyLogicalSize(logical); // stashes the half box + mm::Coord3D p{x, y, z}; + m.modifyLogical(p); + return p; +} + +// modifyLogicalSize halves each mirrored axis, rounding up: an even 128 → 64, an odd +// 65 → 33 (the centre column stays unpaired). +TEST_CASE("MirrorModifier halves each mirrored axis, rounding up") { + mm::MirrorModifier m; // all three axes mirror by default + CHECK(mirrorSize(m, {128, 64, 4}) == mm::Coord3D{64, 32, 2}); + CHECK(mirrorSize(m, {65, 65, 65}) == mm::Coord3D{33, 33, 33}); +} + +// A coord and its mirror across the box centre map to the same logical position: on an +// even 8-wide axis, physical 7 folds to 0, 6 to 1, 5 to 2, 4 to 3, while the near half +// 0..3 passes through unchanged. +TEST_CASE("MirrorModifier maps a coord and its mirror to the same logical position") { + mm::MirrorModifier m; + m.mirrorY = m.mirrorZ = false; // isolate the x fold + const mm::Coord3D box{8, 1, 1}; + + for (mm::lengthType x = 0; x < 4; x++) { + CHECK(fold(m, x, 0, 0, box).x == x); // near half passes through + CHECK(fold(m, 7 - x, 0, 0, box).x == x); // far half reflects onto it + } +} + +// An odd extent keeps its centre column unpaired: on a 5-wide axis the half-extent is 3, +// so 0,1,2 pass through and the far edge 4→1, 3→2, leaving logical column 2 unpaired. +TEST_CASE("MirrorModifier leaves the centre column unpaired on an odd axis") { + mm::MirrorModifier m; + m.mirrorY = m.mirrorZ = false; + const mm::Coord3D box{5, 1, 1}; + CHECK(mirrorSize(m, box).x == 3); // (5+1)/2 + + CHECK(fold(m, 0, 0, 0, box).x == 0); + CHECK(fold(m, 1, 0, 0, box).x == 1); + CHECK(fold(m, 2, 0, 0, box).x == 2); // unpaired centre column + CHECK(fold(m, 3, 0, 0, box).x == 2); // far edge folds onto the centre + CHECK(fold(m, 4, 0, 0, box).x == 1); // half*2-1-4 = 3*2-1-4 = 1 +} + +// A disabled axis is left untouched: with only Y mirrored, X and Z sizes are unchanged +// and X coordinates pass through while Y folds. +TEST_CASE("MirrorModifier leaves a disabled axis untouched") { + mm::MirrorModifier m; + m.mirrorX = false; m.mirrorY = true; m.mirrorZ = false; + const mm::Coord3D box{8, 8, 4}; + CHECK(mirrorSize(m, box) == mm::Coord3D{8, 4, 4}); // only y halves + + CHECK(fold(m, 7, 0, 0, box).x == 7); // x untouched (not mirrored) + CHECK(fold(m, 0, 7, 0, box).y == 0); // y=7 folds onto near half (half*2-1-7 = 0) + CHECK(fold(m, 3, 3, 2, box).z == 2); // z untouched +} + +// Degenerate axes don't crash: a 1-wide axis stays 1 ((1+1)/2 == 1) and no coordinate +// ever reaches the half-extent to fold; a 0-extent axis stays 0. +TEST_CASE("MirrorModifier handles degenerate grids") { + mm::MirrorModifier m; + CHECK(mirrorSize(m, {1, 1, 1}) == mm::Coord3D{1, 1, 1}); + CHECK(fold(m, 0, 0, 0, {1, 1, 1}) == mm::Coord3D{0, 0, 0}); // no fold on a 1x1x1 + CHECK(mirrorSize(m, {0, 0, 0}) == mm::Coord3D{0, 0, 0}); // 0x0x0 doesn't crash +} diff --git a/test/unit/light/unit_Noise2DEffect.cpp b/test/unit/light/unit_Noise2DEffect.cpp new file mode 100644 index 00000000..1dfaf085 --- /dev/null +++ b/test/unit/light/unit_Noise2DEffect.cpp @@ -0,0 +1,90 @@ +// @module Noise2DEffect + +#include "doctest.h" +#include "light/effects/Noise2DEffect.h" +#include "light/layouts/GridLayout.h" + +// A single frame on an 8×8 grid fills the buffer with a palette-mapped noise field (non-zero). +TEST_CASE("Noise2DEffect writes a non-zero palette-mapped noise field") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 8; + grid.height = 8; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::Noise2DEffect noise; + layer.addChild(&noise); + + layer.onBuildState(); + // Palettes::active() is a process-wide static any prior test can mutate; pin a colourful palette + // (Rainbow=0) so the non-black assertion is order-independent. + mm::Palettes::setActive(0); + layer.loop(); + + auto& buf = layer.buffer(); + REQUIRE(buf.data() != nullptr); + REQUIRE(buf.count() == 64); + + bool hasNonZero = false; + for (size_t i = 0; i < buf.bytes(); i++) { + if (buf.data()[i] != 0) { hasNonZero = true; break; } + } + CHECK(hasNonZero); +} + +// The field is spatial: distant pixels read different noise samples, so their colours differ. +TEST_CASE("Noise2DEffect distant pixels carry different colours") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 16; + grid.height = 16; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::Noise2DEffect noise; + noise.scale = 64; // default zoom: at (0,0) vs (8,8) the noise coords are (0,0) vs (512,512) + layer.addChild(&noise); + + layer.onBuildState(); + mm::Palettes::setActive(0); + layer.loop(); + + auto* data = layer.buffer().data(); + uint8_t r0 = data[0], g0 = data[1], b0 = data[2]; + size_t idx88 = (8 * 16 + 8) * 3; + uint8_t r1 = data[idx88], g1 = data[idx88 + 1], b1 = data[idx88 + 2]; + // Value noise is smooth but not constant; widely separated coords sample different field values, + // which index different palette entries. + CHECK((r0 != r1 || g0 != g1 || b0 != b1)); +} + +// Effects must run at every grid size: a 0×0×0 layer renders without crashing (the cols/rows guard). +TEST_CASE("Noise2DEffect survives a degenerate 0x0 grid") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 0; + grid.height = 0; + grid.depth = 0; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::Noise2DEffect noise; + layer.addChild(&noise); + + layer.onBuildState(); + layer.loop(); // must not crash on an empty grid + + CHECK(layer.buffer().count() == 0); +} diff --git a/test/unit/light/unit_NoiseMeterEffect.cpp b/test/unit/light/unit_NoiseMeterEffect.cpp new file mode 100644 index 00000000..c1483c93 --- /dev/null +++ b/test/unit/light/unit_NoiseMeterEffect.cpp @@ -0,0 +1,174 @@ +// @module NoiseMeterEffect +// @also AudioModule + +#include "doctest.h" +#include "light/effects/NoiseMeterEffect.h" +#include "light/layouts/GridLayout.h" +#include "core/AudioModule.h" + +// NoiseMeter is an audio-reactive 1D effect: a vertical VU column whose height tracks the overall sound +// level and whose colour is a scrolling 2D noise field. It writes only the x=0 column and Layer::extrude +// fans each lit row across every x (and z), so a lit row is a complete horizontal band. The column fills +// bottom-up: row y=0 lights first (the floor is buffer row height-1, since drawY = sizeY-1-y). The frame +// comes from AudioModule::latestFrame() (a process-wide static); on the host with no I2S mic we run a +// live AudioModule with `simulate` set to an "always" mode so synthesizeFrame() fills the level each +// loop(). Every audio case brackets its own AudioModule setup()/teardown() so it never leaks the +// active-mic pointer into another test file. Buffer index = (y*width + x)*3. + +// Helper: is any byte of the pixel at (x,y) on a width-W grid non-zero. +static bool pixelLit(mm::Layer& layer, int x, int y, int W) { + auto* d = layer.buffer().data(); + size_t idx = (static_cast<size_t>(y) * W + x) * 3; + return d[idx] || d[idx + 1] || d[idx + 2]; +} + +// With no live audio source the level is 0, so no row lights and the fading buffer settles to black. +TEST_CASE("NoiseMeterEffect fades to dark without an audio frame") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 8; + grid.height = 8; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::NoiseMeterEffect meter; + layer.addChild(&meter); + + layer.onBuildState(); + // No AudioModule is active → latestFrame() is the static all-silence frame (level 0). Each loop fades + // then reads silence → maxLen 0 → nothing drawn, so the buffer decays to fully dark. + for (int i = 0; i < 16; i++) layer.loop(); + + auto& buf = layer.buffer(); + REQUIRE(buf.count() == 64); + bool anyLit = false; + for (size_t i = 0; i < buf.bytes(); i++) { + if (buf.data()[i] != 0) { anyLit = true; break; } + } + CHECK_FALSE(anyLit); +} + +// Fed a loud audio frame the meter fills from the floor upward: a lit row above the floor implies every +// row below it (down to the floor) is also lit — the column never floats. Extrude also means a lit row +// is complete across x, so column 0 and the last column of a lit row agree. +TEST_CASE("NoiseMeterEffect fills the column from the floor upward") { + mm::AudioModule audio; + audio.onBuildControls(); + audio.simulate = 3; // music (always): a swelling non-zero level every tick, so rows light up + audio.setup(); + + const int W = 8, H = 8; + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = W; + grid.height = H; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + mm::Palettes::setActive(0); // colourful palette so a drawn pixel is non-black (order-independent) + + mm::NoiseMeterEffect meter; + meter.fadeRate = 254; // fastest fade so a lit row this frame is this frame's fill, not a stale trail + layer.addChild(&meter); + + layer.onBuildState(); + + // Run frames until the meter rises, then assert the bottom-up invariant on the tallest lit column. + bool sawFill = false; + for (int i = 0; i < 64; i++) { + audio.loop(); + layer.loop(); + // Floor is buffer row H-1 (drawY = sizeY-1-y, y=0 draws there first). + if (pixelLit(layer, 0, H - 1, W)) { + sawFill = true; + // Walk up from the floor: once a dark row is found, every row above it must also be dark + // (a contiguous fill from the bottom, no floating segment). + bool darkSeen = false; + for (int y = H - 1; y >= 0; y--) { + bool lit = pixelLit(layer, 0, y, W); + if (!lit) darkSeen = true; + if (lit) CHECK_FALSE(darkSeen); + // Extrude fans the x=0 column across x: a lit row is lit at the far column too. + CHECK(pixelLit(layer, W - 1, y, W) == lit); + } + } + } + CHECK(sawFill); // the meter rose off the floor during the run + + audio.teardown(); +} + +// The `width` gain control scales level→length: width=0 zeroes the length (tmpSound2 = level*2*0/255), +// so even a loud audio frame lights no row and the buffer stays dark. +TEST_CASE("NoiseMeterEffect width 0 keeps the meter dark despite loud audio") { + mm::AudioModule audio; + audio.onBuildControls(); + audio.simulate = 3; // loud music every tick + audio.setup(); + + const int W = 8, H = 8; + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = W; + grid.height = H; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + mm::Palettes::setActive(0); + + mm::NoiseMeterEffect meter; + meter.width = 0; // gain 0 → maxLen 0 → no row drawn, regardless of level + layer.addChild(&meter); + + layer.onBuildState(); + for (int i = 0; i < 16; i++) { audio.loop(); layer.loop(); } + + bool anyLit = false; + for (size_t i = 0; i < layer.buffer().bytes(); i++) { + if (layer.buffer().data()[i] != 0) { anyLit = true; break; } + } + CHECK_FALSE(anyLit); + + audio.teardown(); +} + +// The "runs at every grid size" hard rule: 0×0×0 and 1×1 both render with a live audio frame every tick +// without crashing (the sizeX/sizeY<=0 early-out and the maxLen constrain cover them). +TEST_CASE("NoiseMeterEffect survives degenerate grid sizes") { + mm::AudioModule audio; + audio.onBuildControls(); + audio.simulate = 3; + audio.setup(); + + for (auto dims : {mm::Coord3D{0, 0, 0}, mm::Coord3D{1, 1, 1}}) { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = dims.x; + grid.height = dims.y; + grid.depth = dims.z; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::NoiseMeterEffect meter; + layer.addChild(&meter); + + layer.onBuildState(); + for (int i = 0; i < 4; i++) { audio.loop(); layer.loop(); } + } + CHECK(true); // no crash at 0×0×0 or 1×1 + + audio.teardown(); +} diff --git a/test/unit/light/unit_PaintBrushEffect.cpp b/test/unit/light/unit_PaintBrushEffect.cpp new file mode 100644 index 00000000..4b883cf9 --- /dev/null +++ b/test/unit/light/unit_PaintBrushEffect.cpp @@ -0,0 +1,175 @@ +// @module PaintBrushEffect +// @also AudioModule + +#include "doctest.h" +#include "light/effects/PaintBrushEffect.h" +#include "light/layouts/GridLayout.h" +#include "core/AudioModule.h" +#include "platform/platform.h" // setTestNowMs — deterministic virtual time + +// PaintBrushEffect is audio-driven: it draws a set of oscillating lines whose length is scaled by an +// audio band's magnitude (bands from AudioModule::latestFrame(), a process-wide static), fading the +// field a little each frame so the moving strokes leave trails. A line only draws when it is longer +// than minLength, and a band of 0 yields length 0, so silence draws nothing and fades to dark. To feed +// a signal on the host (no I2S mic) a live AudioModule runs in a simulate "always" mode — synthesizeFrame() +// fills the bands each loop() off platform::millis(). The clock is frozen with setTestNowMs so the frame +// (and the effect's own oscillators, which read elapsed()==millis()) are deterministic. Each case that +// needs audio brackets its own AudioModule setup()/teardown() via the guard so it never leaks the +// active-mic pointer or the frozen clock into another test file. + +namespace { + +// Restores real-clock behaviour and vacates the process-wide active-mic seat so each case is +// independent (both are global state a prior case could leave set). +struct AudioGuard { + mm::AudioModule& mic; + ~AudioGuard() { + mic.teardown(); // clears AudioModule::active_ if it is this mic + mm::platform::setTestNowMs(0); // restore the platform clock + } +}; + +// Bring the mic up in "music (always)" at a frozen time so every band carries a magnitude and the +// synthesized frame is deterministic; several loops let the levelSmoothed EMA settle. +void driveMusic(mm::AudioModule& mic, uint32_t ms) { + mic.onBuildControls(); + mic.simulate = 3; // music, always — keeps every band non-zero (loud, broadband) + mm::platform::setTestNowMs(ms); + mic.setup(); + for (int i = 0; i < 8; i++) mic.loop(); // fill the frame off the frozen clock +} + +} // namespace + +// A live broadband signal draws strokes: at least some lights are lit after a frame. +TEST_CASE("PaintBrushEffect draws lit strokes from a live audio frame") { + mm::AudioModule mic; + AudioGuard guard{mic}; + driveMusic(mic, 500); + // Sanity: the synthesized frame actually carries band energy the effect can react to. + const mm::AudioFrame* f = mm::AudioModule::latestFrame(); + bool anyBand = false; + for (uint8_t b = 0; b < 16; b++) if (f->bands[b] > 0) anyBand = true; + REQUIRE(anyBand); + + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 16; + grid.height = 16; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::PaintBrushEffect fx; + fx.minLength = 0; // no length gate: any stroke with band energy draws + layer.addChild(&fx); + layer.onBuildState(); + + layer.loop(); // draws the oscillating lines for the current (frozen) frame + + auto& buf = layer.buffer(); + REQUIRE(buf.count() == 256); + bool anyLit = false; + for (size_t i = 0; i < buf.bytes(); i++) { + if (buf.data()[i] != 0) { anyLit = true; break; } + } + CHECK(anyLit); +} + +// Silence draws nothing: with no active mic the frame is all-zero bands, so every line's length maps to +// 0 (below the minLength gate) and the buffer stays fully black. +TEST_CASE("PaintBrushEffect stays black on silence") { + // Ensure no mic holds the active seat, so latestFrame() is the static all-zero frame. + { mm::AudioModule idle; idle.teardown(); } + mm::platform::setTestNowMs(1000); + REQUIRE(mm::AudioModule::latestFrame()->bands[0] == 0); + + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 16; + grid.height = 16; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::PaintBrushEffect fx; + layer.addChild(&fx); + layer.onBuildState(); + + // Run several frames: the per-frame fade only decays what's there, and silence never adds a stroke, + // so the field never lights. + for (int i = 0; i < 8; i++) layer.loop(); + + auto& buf = layer.buffer(); + REQUIRE(buf.count() == 256); + bool anyLit = false; + for (size_t i = 0; i < buf.bytes(); i++) { + if (buf.data()[i] != 0) { anyLit = true; break; } + } + CHECK_FALSE(anyLit); +} + +// The minLength gate suppresses strokes: raised to its maximum, no line is ever long enough to draw, so +// even a loud broadband frame leaves the buffer black — the gate, not the audio, decides. +TEST_CASE("PaintBrushEffect minLength gate suppresses all strokes") { + mm::AudioModule mic; + AudioGuard guard{mic}; + driveMusic(mic, 500); + + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 16; + grid.height = 16; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::PaintBrushEffect fx; + fx.minLength = 255; // length is a 0..255 fraction; "> 255" is never true → no line draws + layer.addChild(&fx); + layer.onBuildState(); + + layer.loop(); + + auto& buf = layer.buffer(); + bool anyLit = false; + for (size_t i = 0; i < buf.bytes(); i++) { + if (buf.data()[i] != 0) { anyLit = true; break; } + } + CHECK_FALSE(anyLit); +} + +// The "runs at every grid size" hard rule: degenerate grids never crash, with a live frame each tick. +TEST_CASE("PaintBrushEffect survives degenerate grid sizes") { + mm::AudioModule mic; + AudioGuard guard{mic}; + driveMusic(mic, 500); + + for (auto dims : {mm::Coord3D{0, 0, 0}, mm::Coord3D{1, 1, 1}}) { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = dims.x; + grid.height = dims.y; + grid.depth = dims.z; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::PaintBrushEffect fx; + layer.addChild(&fx); + layer.onBuildState(); + for (int i = 0; i < 4; i++) { mic.loop(); layer.loop(); } // must not crash on 0×0×0 or 1×1×1 + } + CHECK(true); +} diff --git a/test/unit/light/unit_PinwheelModifier.cpp b/test/unit/light/unit_PinwheelModifier.cpp index e390c297..dc4617c4 100644 --- a/test/unit/light/unit_PinwheelModifier.cpp +++ b/test/unit/light/unit_PinwheelModifier.cpp @@ -56,6 +56,6 @@ TEST_CASE("PinwheelModifier 1D maps the sweep across distinct petals on Y") { CHECK(pos.y < 8); // within the reshaped petals-on-y box petalYs.insert(pos.y); } - // The sweep must touch more than one petal — the whole point of the effect. (Was 1 before the fix.) + // The sweep must touch more than one petal — the whole point of the effect. CHECK(petalYs.size() > 1); } diff --git a/test/unit/light/unit_PraxisEffect.cpp b/test/unit/light/unit_PraxisEffect.cpp new file mode 100644 index 00000000..d2d4d9fe --- /dev/null +++ b/test/unit/light/unit_PraxisEffect.cpp @@ -0,0 +1,96 @@ +// @module PraxisEffect + +#include "doctest.h" +#include "light/effects/PraxisEffect.h" +#include "light/layouts/GridLayout.h" + +// Praxis overwrites EVERY pixel each frame (a full-grid palette field, no black +// background) — with a non-black palette active, no light is left at (0,0,0). +TEST_CASE("PraxisEffect fills every pixel from the palette") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 8; + grid.height = 8; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::PraxisEffect praxis; + layer.addChild(&praxis); + + layer.onBuildState(); + // Rainbow palette (0) is generated at full saturation/value, so every wheel index + // maps to a lit colour — makes "every pixel lit" order-independent of prior tests. + mm::Palettes::setActive(0); + layer.loop(); + + auto& buf = layer.buffer(); + REQUIRE(buf.data() != nullptr); + REQUIRE(buf.count() == 64); + + // No pixel is black: Praxis writes every (x,y) rather than painting sparsely. + auto* data = buf.data(); + bool everyPixelLit = true; + for (size_t p = 0; p < buf.count(); p++) { + uint8_t r = data[p * 3], g = data[p * 3 + 1], b = data[p * 3 + 2]; + if (r == 0 && g == 0 && b == 0) { everyPixelLit = false; break; } + } + CHECK(everyPixelLit); +} + +// The hue is a function of (x, y): pixels far apart in the grid carry different colours, +// so the field is spatial, not a uniform fill. +TEST_CASE("PraxisEffect varies colour across the grid") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 16; + grid.height = 16; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::PraxisEffect praxis; + layer.addChild(&praxis); + + layer.onBuildState(); + mm::Palettes::setActive(0); + layer.loop(); + + auto* data = layer.buffer().data(); + // The y·macro·x cross term makes far-apart pixels land on different hues. Compare the + // origin with the far corner (15,15), where the spatial term is at its largest. + uint8_t r0 = data[0], g0 = data[1], b0 = data[2]; + size_t idx = (15 * 16 + 15) * 3; + uint8_t r1 = data[idx], g1 = data[idx + 1], b1 = data[idx + 2]; + CHECK((r0 != r1 || g0 != g1 || b0 != b1)); +} + +// Hard rule: the effect runs at a degenerate grid without crashing. width/height <= 0 +// is guarded, and a 1×1 grid exercises the render loop at its smallest. +TEST_CASE("PraxisEffect survives degenerate grid sizes") { + for (int dim : {0, 1}) { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = dim; + grid.height = dim; + grid.depth = dim; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::PraxisEffect praxis; + layer.addChild(&praxis); + + layer.onBuildState(); + layer.loop(); // must not crash at 0×0×0 or 1×1×1 + CHECK(true); + } +} diff --git a/test/unit/light/unit_RandomEffect.cpp b/test/unit/light/unit_RandomEffect.cpp new file mode 100644 index 00000000..a26f8482 --- /dev/null +++ b/test/unit/light/unit_RandomEffect.cpp @@ -0,0 +1,98 @@ +// @module RandomEffect + +#include "doctest.h" +#include "light/effects/RandomEffect.h" +#include "light/layouts/GridLayout.h" + +// A single frame on a fresh black buffer lights exactly ONE light (one setRGB per frame). +TEST_CASE("RandomEffect lights exactly one light per frame") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 8; + grid.height = 8; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::RandomEffect effect; + effect.fade = 255; // fade every remaining light fully black, isolating this frame's single write + layer.addChild(&effect); + + layer.onBuildState(); + + auto& buf = layer.buffer(); + REQUIRE(buf.data() != nullptr); + REQUIRE(buf.count() == 64); + + layer.loop(); + + // Count lights with any non-black channel. With fade=255 the whole buffer is cleared each frame, + // so precisely the one randomly chosen light survives — the direct equivalent of MoonLight's + // single index-based setRGB. + const uint8_t cpl = buf.channelsPerLight(); + int litLights = 0; + for (size_t i = 0; i < buf.count(); i++) { + const size_t off = i * cpl; + if (buf.data()[off] != 0 || buf.data()[off + 1] != 0 || buf.data()[off + 2] != 0) litLights++; + } + CHECK(litLights == 1); +} + +// Over many frames with light fade the sparkle field fills — more than one light ends up lit. +TEST_CASE("RandomEffect scatters colour across many lights over many frames") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 8; + grid.height = 8; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::RandomEffect effect; + effect.fade = 1; // barely fade, so lit lights linger and the field fills over frames + layer.addChild(&effect); + + layer.onBuildState(); + + // 200 frames each add one sparkle; with almost no fade the field accumulates well past one light. + for (int f = 0; f < 200; f++) layer.loop(); + + auto& buf = layer.buffer(); + const uint8_t cpl = buf.channelsPerLight(); + int litLights = 0; + for (size_t i = 0; i < buf.count(); i++) { + const size_t off = i * cpl; + if (buf.data()[off] != 0 || buf.data()[off + 1] != 0 || buf.data()[off + 2] != 0) litLights++; + } + CHECK(litLights > 1); +} + +// The effect runs at degenerate grid sizes without crashing (Effects-must-run-at-every-grid-size). +TEST_CASE("RandomEffect survives degenerate grids") { + for (mm::lengthType n : {0, 1}) { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = n; + grid.height = n; + grid.depth = n; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::RandomEffect effect; + layer.addChild(&effect); + + layer.onBuildState(); + layer.loop(); // must not crash on 0×0×0 or 1×1×1 + + CHECK(true); // reaching here means no crash / hang + } +} diff --git a/test/unit/light/unit_RippleXZModifier.cpp b/test/unit/light/unit_RippleXZModifier.cpp new file mode 100644 index 00000000..5fe96ee5 --- /dev/null +++ b/test/unit/light/unit_RippleXZModifier.cpp @@ -0,0 +1,74 @@ +// @module RippleXZModifier + +#include "doctest.h" +#include "light/modifiers/RippleXZModifier.h" + +// RippleXZModifier collapses one axis of the logical box to a single plane, so a +// lower-dimensional effect maps identically onto every slice of the physical box. +// modifyLogicalSize sets the collapsed axis extent to 1; modifyLogical folds every +// coordinate on that axis to 0 and never rejects. Defaults: shrink=true, towardsX=true, +// towardsZ=false — X flattened, Z untouched. Y is never collapsed. + +// Fold a coord through the modifier; the returned bool is modifyLogical's accept flag. +static bool fold(const mm::RippleXZModifier& r, mm::Coord3D& p) { + return r.modifyLogical(p); +} + +// The size after collapse for a given physical box. +static mm::Coord3D collapsedSize(mm::RippleXZModifier& r, mm::Coord3D box) { + r.modifyLogicalSize(box); + return box; +} + +// Default collapses X only: size.x becomes 1, Y and Z keep their extent. +TEST_CASE("RippleXZModifier default collapses the X axis to one plane") { + mm::RippleXZModifier r; + CHECK(collapsedSize(r, {16, 8, 4}) == mm::Coord3D{1, 8, 4}); // x flattened, y/z kept + + // Every X folds to x=0; Y and Z pass through unchanged; nothing is rejected. + mm::Coord3D p{7, 3, 2}; + CHECK(fold(r, p)); + CHECK(p == mm::Coord3D{0, 3, 2}); + p = {15, 0, 3}; + CHECK(fold(r, p)); + CHECK(p == mm::Coord3D{0, 0, 3}); +} + +// towardsZ collapses Z instead; both flags collapse X and Z, leaving Y as the only axis. +TEST_CASE("RippleXZModifier collapses Z, and both X and Z together") { + mm::RippleXZModifier z; + z.towardsX = false; z.towardsZ = true; + CHECK(collapsedSize(z, {16, 8, 4}) == mm::Coord3D{16, 8, 1}); // z flattened, x/y kept + mm::Coord3D p{5, 3, 2}; + CHECK(fold(z, p)); + CHECK(p == mm::Coord3D{5, 3, 0}); // z folds to 0, x/y pass through + + mm::RippleXZModifier both; + both.towardsX = true; both.towardsZ = true; + CHECK(collapsedSize(both, {16, 8, 4}) == mm::Coord3D{1, 8, 1}); // only y survives + p = {9, 6, 3}; + CHECK(fold(both, p)); + CHECK(p == mm::Coord3D{0, 6, 0}); // x and z both fold to 0 +} + +// shrink=false is the identity: no axis collapses, no coordinate folds. +TEST_CASE("RippleXZModifier with shrink off is the identity") { + mm::RippleXZModifier r; + r.shrink = false; // towardsX still true, but shrink gates everything + CHECK(collapsedSize(r, {16, 8, 4}) == mm::Coord3D{16, 8, 4}); // unchanged + + mm::Coord3D p{7, 3, 2}; + CHECK(fold(r, p)); + CHECK(p == mm::Coord3D{7, 3, 2}); // coord untouched +} + +// Degenerate boxes don't crash: a 0x0x0 box collapses its X to 1, and folding at the +// origin still accepts and folds x to 0. +TEST_CASE("RippleXZModifier handles a degenerate 0x0x0 box") { + mm::RippleXZModifier r; + CHECK(collapsedSize(r, {0, 0, 0}) == mm::Coord3D{1, 0, 0}); // x floored to the plane + + mm::Coord3D p{0, 0, 0}; + CHECK(fold(r, p)); + CHECK(p == mm::Coord3D{0, 0, 0}); +} diff --git a/test/unit/light/unit_RubiksCubeEffect.cpp b/test/unit/light/unit_RubiksCubeEffect.cpp new file mode 100644 index 00000000..e759af37 --- /dev/null +++ b/test/unit/light/unit_RubiksCubeEffect.cpp @@ -0,0 +1,152 @@ +// @module RubiksCubeEffect + +#include "doctest.h" +#include "light/effects/RubiksCubeEffect.h" +#include "light/layouts/GridLayout.h" +#include "platform/platform.h" // setTestNowMs — freeze millis() so the first frame runs init() deterministically + +#include <vector> + +// Restore the real clock after any test that froze it, so a frozen value can't leak into +// order-dependent neighbours. +namespace { struct ClockGuard { ~ClockGuard() { mm::platform::setTestNowMs(0); } }; } + +// The six sticker colours drawCube() paints from (Red, DarkOrange, Blue, Green, Yellow, White) — +// the only colours a lit voxel may carry. +static bool isRubiksFaceColour(uint8_t r, uint8_t g, uint8_t b) { + static const mm::RGB kMap[6] = { + {255, 0, 0}, {255, 140, 0}, {0, 0, 255}, {0, 128, 0}, {255, 255, 0}, {255, 255, 255}}; + for (const mm::RGB& c : kMap) + if (r == c.r && g == c.g && b == c.b) return true; + return false; +} + +// The first frame scrambles a fresh cube and projects it onto the volume: with millis() past t=0 the +// init() path fires (doInit_ is set at construction), so the buffer holds a drawn cube, not black. +TEST_CASE("RubiksCubeEffect paints the cube on the first frame") { + ClockGuard guard; + mm::platform::setTestNowMs(1); // non-zero millis() so `now > step_` (step_ starts at 0) triggers init + + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 8; + grid.height = 8; + grid.depth = 8; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::RubiksCubeEffect cube; + layer.addChild(&cube); + + layer.onBuildState(); + layer.loop(); + + auto& buf = layer.buffer(); + REQUIRE(buf.count() == 8 * 8 * 8); + + bool anyLit = false; + for (size_t i = 0; i < buf.bytes(); i++) { + if (buf.data()[i] != 0) { anyLit = true; break; } + } + CHECK(anyLit); +} + +// Every lit voxel carries exactly one of the six Rubik's face colours — the projection only ever +// writes COLOR_MAP entries, never a blended or arbitrary RGB. +TEST_CASE("RubiksCubeEffect only paints the six face colours") { + ClockGuard guard; + mm::platform::setTestNowMs(1); + + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 8; + grid.height = 8; + grid.depth = 8; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::RubiksCubeEffect cube; + layer.addChild(&cube); + + layer.onBuildState(); + layer.loop(); + + auto& buf = layer.buffer(); + for (size_t i = 0; i + 2 < buf.bytes(); i += 3) { + uint8_t r = buf.data()[i], g = buf.data()[i + 1], b = buf.data()[i + 2]; + if (r == 0 && g == 0 && b == 0) continue; // interior/background voxel — drawCube leaves it black + CHECK(isRubiksFaceColour(r, g, b)); + } +} + +// turnsPerSecond=0 disables the turn pacing (loop() returns before rotating), but the cube is still +// drawn on the first frame — init() runs and paints before the turn gate is reached. +TEST_CASE("RubiksCubeEffect with turnsPerSecond=0 still draws but never turns") { + ClockGuard guard; + mm::platform::setTestNowMs(1); + + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 8; + grid.height = 8; + grid.depth = 8; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::RubiksCubeEffect cube; + cube.turnsPerSecond = 0; + layer.addChild(&cube); + + layer.onBuildState(); + layer.loop(); + + auto& buf = layer.buffer(); + bool anyLit = false; + for (size_t i = 0; i < buf.bytes(); i++) { + if (buf.data()[i] != 0) { anyLit = true; break; } + } + CHECK(anyLit); + + // Advancing time far past any turn interval must not change the drawn frame: no turn ever fires, + // so a full second later the buffer is byte-for-byte identical. + std::vector<uint8_t> before(buf.data(), buf.data() + buf.bytes()); + mm::platform::setTestNowMs(2000); + layer.loop(); + std::vector<uint8_t> after(buf.data(), buf.data() + buf.bytes()); + CHECK(before == after); +} + +// The effect runs at a degenerate grid size without crashing (the "every grid size" hard rule): +// loop() bails on a zero extent and the buffer stays empty. +TEST_CASE("RubiksCubeEffect survives a 0x0x0 grid") { + ClockGuard guard; + mm::platform::setTestNowMs(1); + + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 0; + grid.height = 0; + grid.depth = 0; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::RubiksCubeEffect cube; + layer.addChild(&cube); + + layer.onBuildState(); + layer.loop(); + + CHECK(layer.buffer().count() == 0); +} diff --git a/test/unit/light/unit_SolidEffect.cpp b/test/unit/light/unit_SolidEffect.cpp new file mode 100644 index 00000000..7816c983 --- /dev/null +++ b/test/unit/light/unit_SolidEffect.cpp @@ -0,0 +1,130 @@ +// @module SolidEffect + +#include "doctest.h" +#include "light/effects/SolidEffect.h" +#include "light/layouts/GridLayout.h" + +// Mode 0 (RGB(W)) fills the whole buffer with one uniform colour: every light equals red/green/blue. +TEST_CASE("SolidEffect mode 0 fills the buffer with one uniform colour") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 4; + grid.height = 4; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::SolidEffect solid; + solid.colorMode = 0; + solid.red = 100; + solid.green = 50; + solid.blue = 25; + solid.brightness = 255; // brightness 255 leaves the colour unscaled + layer.addChild(&solid); + + layer.onBuildState(); + layer.loop(); + + auto& buf = layer.buffer(); + REQUIRE(buf.count() == 16); + // Every light carries exactly the configured RGB — the whole grid is one flat colour. + for (size_t i = 0; i < buf.count(); i++) { + CHECK(buf.data()[i * 3 + 0] == 100); + CHECK(buf.data()[i * 3 + 1] == 50); + CHECK(buf.data()[i * 3 + 2] == 25); + } +} + +// Brightness scales the flat colour down per channel (channel * brightness / 255). +TEST_CASE("SolidEffect mode 0 scales the flat colour by brightness") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 2; + grid.height = 2; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::SolidEffect solid; + solid.colorMode = 0; + solid.red = 200; + solid.green = 100; + solid.blue = 0; + solid.brightness = 128; // half brightness roughly halves each channel + layer.addChild(&solid); + + layer.onBuildState(); + layer.loop(); + + auto& buf = layer.buffer(); + // 200*128/255 = 100, 100*128/255 = 50, 0 stays 0 — uniform across the grid. + for (size_t i = 0; i < buf.count(); i++) { + CHECK(buf.data()[i * 3 + 0] == static_cast<uint8_t>(200 * 128 / 255)); + CHECK(buf.data()[i * 3 + 1] == static_cast<uint8_t>(100 * 128 / 255)); + CHECK(buf.data()[i * 3 + 2] == 0); + } +} + +// On an RGBW layer mode 0 writes the white channel too (white scaled by brightness). +TEST_CASE("SolidEffect mode 0 writes the white channel on an RGBW layer") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 3; + grid.height = 1; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(4); // RGBW + + mm::SolidEffect solid; + solid.colorMode = 0; + solid.red = 10; + solid.green = 20; + solid.blue = 30; + solid.white = 200; + solid.brightness = 255; + layer.addChild(&solid); + + layer.onBuildState(); + layer.loop(); + + auto& buf = layer.buffer(); + for (size_t i = 0; i < buf.count(); i++) { + CHECK(buf.data()[i * 4 + 0] == 10); + CHECK(buf.data()[i * 4 + 1] == 20); + CHECK(buf.data()[i * 4 + 2] == 30); + CHECK(buf.data()[i * 4 + 3] == 200); // white channel carries the configured white + } +} + +// The effect runs at a degenerate 0×0×0 grid and at every colour mode without crashing. +TEST_CASE("SolidEffect survives a 0x0x0 grid across all colour modes") { + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 0; + grid.height = 0; + grid.depth = 0; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::SolidEffect solid; + layer.addChild(&solid); + + for (uint8_t mode = 0; mode < mm::SolidEffect::kColorModeCount; mode++) { + solid.colorMode = mode; + layer.onBuildState(); + layer.loop(); // must not crash on an empty grid + } + CHECK(true); +} diff --git a/test/unit/light/unit_SphereMoveEffect.cpp b/test/unit/light/unit_SphereMoveEffect.cpp new file mode 100644 index 00000000..fa6fde03 --- /dev/null +++ b/test/unit/light/unit_SphereMoveEffect.cpp @@ -0,0 +1,101 @@ +// @module SphereMoveEffect + +#include "doctest.h" +#include "light/effects/SphereMoveEffect.h" +#include "light/layouts/GridLayout.h" + +// Helper: build a Layer hosting a SphereMoveEffect on a w×h×d grid. +static void buildSphere(mm::Layouts& layouts, mm::GridLayout& grid, mm::Layer& layer, + mm::SphereMoveEffect& sphere, int w, int h, int d) { + grid.width = static_cast<mm::lengthType>(w); + grid.height = static_cast<mm::lengthType>(h); + grid.depth = static_cast<mm::lengthType>(d); + layouts.addChild(&grid); + + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + layer.addChild(&sphere); + layer.onBuildState(); +} + +// The effect fully clears the buffer each frame, so a thin shell leaves the vast majority of a large +// volume black (it is a hollow surface, not a solid fill). +TEST_CASE("SphereMoveEffect leaves most of a large volume dark (thin shell, full clear)") { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + mm::SphereMoveEffect sphere; + buildSphere(layouts, grid, layer, sphere, 12, 12, 12); + + // Pre-paint every channel white so we can observe the per-frame full clear: any pixel the shell + // does not touch must return to black. + auto& buf = layer.buffer(); + for (size_t i = 0; i < buf.bytes(); i++) buf.data()[i] = 255; + + layer.loop(); + + size_t lit = 0; + for (size_t p = 0; p < buf.count(); p++) { + const uint8_t* px = buf.data() + p * 3; + if (px[0] || px[1] || px[2]) lit++; + } + // The one-unit-thick shell of a diameter ~2..3 sphere is a small fraction of a 12³ = 1728 voxel + // volume — far below half. This pins "thin surface + full clear", not "renders something". + CHECK(lit < buf.count() / 2); +} + +// Every voxel the effect lights is a real palette colour (non-black) — the shell is drawn, not +// left as leftover noise. +TEST_CASE("SphereMoveEffect only writes non-black palette colours") { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + mm::SphereMoveEffect sphere; + buildSphere(layouts, grid, layer, sphere, 16, 16, 16); + + // Rainbow palette (index 0) is generated at full saturation/value, so no entry is black — a lit + // shell voxel is therefore always non-black. Palettes::active() is a process-wide static, so pin it. + mm::Palettes::setActive(0); + layer.loop(); + + // Any pixel that is set has all-black or a genuine colour; because the buffer was zero-initialised + // and cleared, every non-zero pixel here is a shell voxel. Assert the shell exists and is coloured. + auto& buf = layer.buffer(); + bool anyLit = false; + for (size_t p = 0; p < buf.count(); p++) { + const uint8_t* px = buf.data() + p * 3; + if (px[0] || px[1] || px[2]) { anyLit = true; break; } + } + // A diameter ~2..3 shell on a 16³ grid with an origin inside the volume lights some voxels; the + // colour comes from the palette so it is never a stray single-channel artifact. + CHECK(anyLit); +} + +// The effect is 3D-native: it declares D3 dimensions. +TEST_CASE("SphereMoveEffect reports 3D dimensions") { + mm::SphereMoveEffect sphere; + CHECK(sphere.dimensions() == mm::Dim::D3); +} + +// Hard rule: the effect must run at any grid size without crashing, including a 0×0×0 volume and a +// 1×1×1 volume (the loop guards w/h/d <= 0 and clamps speed so 100-speed is never zero). +TEST_CASE("SphereMoveEffect survives degenerate grids") { + { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + mm::SphereMoveEffect sphere; + buildSphere(layouts, grid, layer, sphere, 0, 0, 0); + layer.loop(); // must not crash + CHECK(layer.buffer().count() == 0); + } + { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + mm::SphereMoveEffect sphere; + buildSphere(layouts, grid, layer, sphere, 1, 1, 1); + layer.loop(); // must not crash + CHECK(layer.buffer().count() == 1); + } +} diff --git a/test/unit/light/unit_StarFieldEffect.cpp b/test/unit/light/unit_StarFieldEffect.cpp new file mode 100644 index 00000000..4861a44f --- /dev/null +++ b/test/unit/light/unit_StarFieldEffect.cpp @@ -0,0 +1,155 @@ +// @module StarFieldEffect + +#include "doctest.h" +#include "light/effects/StarFieldEffect.h" +#include "light/layouts/GridLayout.h" +#include "platform/platform.h" // setTestNowMs — drive the throttle past its interval deterministically + +// Restore the real clock on scope exit, even if a REQUIRE aborts the case mid-way, +// so a frozen millis() can't leak into a later test (same pattern as unit_BouncingBallsEffect.cpp). +namespace { struct ClockGuard { ~ClockGuard() { mm::platform::setTestNowMs(0); } }; } + +// A frame past the speed throttle interval lights at least one star (greyscale, so every lit pixel +// is a pure grey R==G==B) — the field advances and re-projects stars onto the panel. +TEST_CASE("StarFieldEffect paints greyscale stars once the throttle elapses") { + ClockGuard guard; + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 16; + grid.height = 16; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::StarFieldEffect stars; + stars.numStars = 255; // many stars so at least one projects on-panel + stars.usePalette = false; + layer.addChild(&stars); + layer.onBuildState(); + + // speed=20 → throttle 1000/20 = 50 ms; step_ starts at 0, so millis()=100 clears the gate. + mm::platform::setTestNowMs(100); + layer.loop(); + + auto* data = layer.buffer().data(); + const size_t count = layer.buffer().count(); + REQUIRE(data != nullptr); + REQUIRE(count == 256); + + bool anyLit = false; + bool allGrey = true; + for (size_t i = 0; i < count; i++) { + const uint8_t r = data[i * 3], g = data[i * 3 + 1], b = data[i * 3 + 2]; + if (r || g || b) { + anyLit = true; + if (!(r == g && g == b)) allGrey = false; // greyscale: R==G==B for every lit star + } + } + CHECK(anyLit); + CHECK(allGrey); + +} + +// speed=0 pauses the field: the buffer stays fully black no matter how much virtual time passes. +TEST_CASE("StarFieldEffect at speed 0 leaves the buffer black") { + ClockGuard guard; + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 16; + grid.height = 16; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::StarFieldEffect stars; + stars.speed = 0; // paused + stars.numStars = 255; + layer.addChild(&stars); + layer.onBuildState(); + + mm::platform::setTestNowMs(1000); + layer.loop(); + + auto& buf = layer.buffer(); + bool allBlack = true; + for (size_t i = 0; i < buf.bytes(); i++) { + if (buf.data()[i] != 0) { allBlack = false; break; } + } + CHECK(allBlack); + +} + +// The palette variant lights on-panel stars in colour (not forced grey) — usePalette drives hue. +TEST_CASE("StarFieldEffect with usePalette lights stars from the palette") { + ClockGuard guard; + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 16; + grid.height = 16; + grid.depth = 1; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::StarFieldEffect stars; + stars.numStars = 255; + stars.usePalette = true; + layer.addChild(&stars); + layer.onBuildState(); + + // Rainbow palette (0), generated at full saturation/value so entries are colourful, not grey — + // Palettes::active() is a process-wide static any prior test can mutate, so pin it here. + mm::Palettes::setActive(0); + + mm::platform::setTestNowMs(100); + layer.loop(); + + auto* data = layer.buffer().data(); + const size_t count = layer.buffer().count(); + bool anyLit = false; + bool anyColoured = false; + for (size_t i = 0; i < count; i++) { + const uint8_t r = data[i * 3], g = data[i * 3 + 1], b = data[i * 3 + 2]; + if (r || g || b) { + anyLit = true; + if (!(r == g && g == b)) { anyColoured = true; break; } // a non-grey pixel = palette colour + } + } + CHECK(anyLit); + CHECK(anyColoured); + +} + +// Hard rule: the effect runs at a degenerate 0×0×0 grid without crashing (it allocates nothing and +// the loop bails on the zero dimensions). +TEST_CASE("StarFieldEffect survives a 0x0x0 grid") { + ClockGuard guard; + mm::Layouts layouts; + mm::GridLayout grid; + grid.width = 0; + grid.height = 0; + grid.depth = 0; + layouts.addChild(&grid); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + mm::StarFieldEffect stars; + layer.addChild(&stars); + + layer.onBuildState(); + mm::platform::setTestNowMs(100); + layer.loop(); // must not crash + + CHECK(layer.buffer().count() == 0); + +} diff --git a/test/unit/light/unit_StarSkyEffect.cpp b/test/unit/light/unit_StarSkyEffect.cpp new file mode 100644 index 00000000..5f5530a9 --- /dev/null +++ b/test/unit/light/unit_StarSkyEffect.cpp @@ -0,0 +1,116 @@ +// @module StarSkyEffect + +#include "doctest.h" +#include "light/effects/StarSkyEffect.h" +#include "light/layouts/GridLayout.h" + +// Helper: build a layer around a grid and attach a StarSky effect, then build state once. +static void wire(mm::Layouts& layouts, mm::GridLayout& grid, mm::Layer& layer, + mm::StarSkyEffect& star, mm::lengthType w, mm::lengthType h, mm::lengthType d) { + grid.width = w; + grid.height = h; + grid.depth = d; + layouts.addChild(&grid); + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + layer.addChild(&star); + layer.onBuildState(); +} + +// A field of stars lights at least some pixels on a populated 3D grid after a frame. +TEST_CASE("StarSkyEffect lights pixels on a populated grid") { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + mm::StarSkyEffect star; + // A large fill ratio guarantees a healthy pool of stars so a lit pixel is near-certain. + star.star_fill_ratio = 255; + star.speed = 10; + wire(layouts, grid, layer, star, 8, 8, 4); + + layer.loop(); + + auto& buf = layer.buffer(); + REQUIRE(buf.data() != nullptr); + REQUIRE(buf.count() == 8 * 8 * 4); + + bool hasNonZero = false; + for (size_t i = 0; i < buf.bytes(); i++) { + if (buf.data()[i] != 0) { hasNonZero = true; break; } + } + CHECK(hasNonZero); +} + +// White stars (usePalette=false) paint only greyscale: every lit pixel has R==G==B. +TEST_CASE("StarSkyEffect white stars paint greyscale pixels") { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + mm::StarSkyEffect star; + star.usePalette = false; + star.star_fill_ratio = 255; + star.speed = 5; + wire(layouts, grid, layer, star, 8, 8, 1); + + layer.loop(); + + auto* data = layer.buffer().data(); + size_t litPixels = 0; + for (size_t p = 0; p < layer.buffer().count(); p++) { + uint8_t r = data[p * 3], g = data[p * 3 + 1], b = data[p * 3 + 2]; + if (r || g || b) { + litPixels++; + // A white star's colour is RGB{b,b,b}: the three channels must be equal. + CHECK(r == g); + CHECK(g == b); + } + } + CHECK(litPixels > 0); +} + +// A zero fill ratio still seeds a pool (nb_stars = ratio*count/10000 + 1) so a lit pixel appears. +TEST_CASE("StarSkyEffect always keeps at least one star") { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + mm::StarSkyEffect star; + star.star_fill_ratio = 0; // 0*count/10000 + 1 == 1 star + star.speed = 20; + wire(layouts, grid, layer, star, 4, 4, 1); + + // Step several frames; the single star keeps fading/respawning, so within a few frames it lights. + bool everLit = false; + for (int f = 0; f < 8 && !everLit; f++) { + layer.loop(); + auto* data = layer.buffer().data(); + for (size_t i = 0; i < layer.buffer().bytes(); i++) { + if (data[i] != 0) { everLit = true; break; } + } + } + CHECK(everLit); +} + +// The effect survives degenerate grids (0x0x0 and 1x1x1) without crashing — the every-grid-size rule. +TEST_CASE("StarSkyEffect runs at degenerate grid sizes") { + { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + mm::StarSkyEffect star; + star.star_fill_ratio = 255; + wire(layouts, grid, layer, star, 0, 0, 0); + layer.loop(); // must not crash on an empty grid + CHECK(layer.buffer().count() == 0); + } + { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + mm::StarSkyEffect star; + star.star_fill_ratio = 255; + star.speed = 30; + wire(layouts, grid, layer, star, 1, 1, 1); + layer.loop(); // single cell — no out-of-bounds + CHECK(layer.buffer().count() == 1); + } +} diff --git a/test/unit/light/unit_TetrixEffect.cpp b/test/unit/light/unit_TetrixEffect.cpp new file mode 100644 index 00000000..e96c377a --- /dev/null +++ b/test/unit/light/unit_TetrixEffect.cpp @@ -0,0 +1,121 @@ +// @module TetrixEffect + +#include "doctest.h" +#include "light/effects/TetrixEffect.h" +#include "light/layouts/GridLayout.h" +#include "platform/platform.h" // setTestNowMs — deterministic virtual time + +// TetrixEffect runs one falling-brick state machine per X column. Every column is seeded with a 2 s +// start delay (step = millis()+2000) in onBuildState(), so nothing renders until that delay elapses; +// once it does the column spawns a brick that falls and stacks. The per-effect Random8 has a fixed +// default seed, so with the clock frozen via setTestNowMs the whole effect is deterministic — the +// cases below freeze/advance virtual time so the state machine crosses its delays predictably. Each +// case restores the real clock through the guard so a frozen clock never leaks into another test file. + +namespace { + +// Builds a layer holding a Tetrix effect on a w×h×1 RGB grid, all children wired. +struct TetrixRig { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + mm::TetrixEffect fx; + + TetrixRig(mm::lengthType w, mm::lengthType h) { + grid.width = w; grid.height = h; grid.depth = 1; + layouts.addChild(&grid); + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + layer.addChild(&fx); + } +}; + +// Restores the real platform clock so a frozen time never leaks past the case that set it. +struct ClockGuard { ~ClockGuard() { mm::platform::setTestNowMs(0); } }; + +bool anyLit(mm::Layer& layer) { + auto& buf = layer.buffer(); + for (size_t i = 0; i < buf.bytes(); i++) if (buf.data()[i] != 0) return true; + return false; +} + +} // namespace + +// During the initial 2 s start delay every column is idle-waiting, so the very first frame renders +// nothing: the buffer is entirely black even though the effect is enabled and built. +TEST_CASE("TetrixEffect renders black during the start delay") { + ClockGuard guard; + mm::platform::setTestNowMs(1000); // freeze; onBuildState seeds step = 1000+2000 + + TetrixRig rig(8, 8); + rig.layer.onBuildState(); + + // Still inside the 2 s start window (3000 > 1000), so the state machine only waits. + rig.layer.loop(); + + CHECK(rig.grid.width * rig.grid.height == 64); + CHECK_FALSE(anyLit(rig.layer)); +} + +// Once virtual time advances past the start delay, columns spawn bricks that fall and render: after a +// span of frames at least one light is lit, and every lit light carries a real (non-black) RGB colour +// pulled from the palette rather than partial/garbage channels. +TEST_CASE("TetrixEffect lights up with palette colour after the start delay") { + ClockGuard guard; + mm::platform::setTestNowMs(0); + + TetrixRig rig(8, 8); + rig.layer.onBuildState(); // step = 2000 for every column + + // Advance well past the 2 s start delay, then run many frames so the start-roll (step 1→2) fires + // and bricks descend into the visible region. Step time forward each frame like a real tick loop. + bool lit = false; + for (uint32_t t = 3000; t <= 8000 && !lit; t += 25) { + mm::platform::setTestNowMs(t); + rig.layer.loop(); + lit = anyLit(rig.layer); + } + REQUIRE(lit); + + // Every non-black light is a full RGB triple from colorFromPalette — assert no lit light is a + // single stray channel (a lit light means at least one channel > 0; the brick colour is a palette + // entry written across all three channels, so a lit pixel is a genuine colour, not noise). + auto& buf = rig.layer.buffer(); + bool foundColoured = false; + for (size_t p = 0; p + 2 < buf.bytes(); p += 3) { + const uint8_t r = buf.data()[p], g = buf.data()[p + 1], b = buf.data()[p + 2]; + if (r || g || b) { foundColoured = true; break; } + } + CHECK(foundColoured); +} + +// Effects must run at every grid size: a degenerate 0×0×0 grid and a 1×1 grid both survive a build + +// several frames across advancing time without crashing (no allocation, no out-of-range write). +TEST_CASE("TetrixEffect survives degenerate and minimal grids") { + ClockGuard guard; + + // 0×0×0: onBuildState allocates zero drops, loop() bails on the w<=0 guard. + { + TetrixRig rig(0, 0); + rig.grid.depth = 0; + mm::platform::setTestNowMs(0); + rig.layer.onBuildState(); + for (uint32_t t = 0; t <= 6000; t += 500) { + mm::platform::setTestNowMs(t); + rig.layer.loop(); + } + CHECK(rig.layer.buffer().count() == 0); + } + + // 1×1: a single column, one row — the brick fills and clears the lone light without crashing. + { + TetrixRig rig(1, 1); + mm::platform::setTestNowMs(0); + rig.layer.onBuildState(); + for (uint32_t t = 0; t <= 8000; t += 25) { + mm::platform::setTestNowMs(t); + rig.layer.loop(); + } + CHECK(rig.layer.buffer().count() == 1); + } +} diff --git a/test/unit/light/unit_TextEffect.cpp b/test/unit/light/unit_TextEffect.cpp index 96c1ae22..61af1bc3 100644 --- a/test/unit/light/unit_TextEffect.cpp +++ b/test/unit/light/unit_TextEffect.cpp @@ -48,6 +48,14 @@ TEST_CASE("TextEffect: static text draws glyph pixels, empty draws nothing") { CHECK(s.litPixels() == 0); } +// The hue default is 128 (mid-palette), not 0: palette index 0 is BLACK in several +// palettes, so a hue-0 default would render invisible text on those. Pinning the +// default guards against a silent regression back to 0. +TEST_CASE("TextEffect: default hue is mid-palette (128), not black-at-0") { + TextEffect text; + CHECK(text.hue == 128); +} + // A multi-line string wraps: the second line renders on a lower row (font-height down), so a // two-line string lights pixels below the first font's height. Uses the 4x6 font (height 6). TEST_CASE("TextEffect: newline wraps to a second row") { diff --git a/test/unit/light/unit_TransposeModifier.cpp b/test/unit/light/unit_TransposeModifier.cpp new file mode 100644 index 00000000..b35d8a9c --- /dev/null +++ b/test/unit/light/unit_TransposeModifier.cpp @@ -0,0 +1,79 @@ +// @module TransposeModifier + +#include "doctest.h" +#include "light/modifiers/TransposeModifier.h" + +// TransposeModifier swaps a pair of axes of the logical box and every coordinate +// folded through it (a matrix transpose: rows become columns), then optionally +// flips each axis back-to-front. modifyLogicalSize swaps the size fields; +// modifyLogical swaps the matching coordinate fields. It never rejects a coord. + +// The transposed box for a given box. +static mm::Coord3D transposedSize(mm::TransposeModifier& t, mm::Coord3D box) { + t.modifyLogicalSize(box); + return box; +} + +// Fold a coord through the modifier (modifyLogicalSize must run first to stash the +// transposed box for the inverse). Returns the folded pos; transpose never rejects. +static mm::Coord3D fold(mm::TransposeModifier& t, mm::lengthType x, mm::lengthType y, + mm::lengthType z, mm::Coord3D box) { + t.modifyLogicalSize(box); // stashes the transposed box + mm::Coord3D p{x, y, z}; + bool kept = t.modifyLogical(p); + CHECK(kept); // transpose accepts every coordinate + return p; +} + +// Default (XY on): x and y swap on both the box and the coordinate; z is untouched. +TEST_CASE("TransposeModifier default swaps x and y") { + mm::TransposeModifier t; + // A wide box becomes a tall box. + CHECK(transposedSize(t, {128, 64, 4}) == mm::Coord3D{64, 128, 4}); + // A coordinate's x and y swap; z stays put. + CHECK(fold(t, 3, 7, 2, {128, 64, 4}) == mm::Coord3D{7, 3, 2}); + // Origin is a fixed point of the swap. + CHECK(fold(t, 0, 0, 0, {128, 64, 4}) == mm::Coord3D{0, 0, 0}); +} + +// XZ swaps x and z; YZ swaps y and z. Only the selected pair moves. +TEST_CASE("TransposeModifier swaps the selected axis pair") { + mm::TransposeModifier xz; + xz.transposeXY = false; xz.transposeXZ = true; + CHECK(transposedSize(xz, {8, 4, 2}) == mm::Coord3D{2, 4, 8}); // x<->z + CHECK(fold(xz, 1, 2, 3, {8, 4, 2}) == mm::Coord3D{3, 2, 1}); // x<->z, y kept + + mm::TransposeModifier yz; + yz.transposeXY = false; yz.transposeYZ = true; + CHECK(transposedSize(yz, {8, 4, 2}) == mm::Coord3D{8, 2, 4}); // y<->z + CHECK(fold(yz, 1, 2, 3, {8, 4, 2}) == mm::Coord3D{1, 3, 2}); // y<->z, x kept +} + +// inverse flips an axis back-to-front within the TRANSPOSED box: x -> size.x-1-x. +// With the default XY swap, inverse X flips the (post-swap) x axis, whose span is the +// original box height. On a {128,64,z} box the transposed x span is 64. +TEST_CASE("TransposeModifier inverse flips within the transposed box") { + mm::TransposeModifier t; // XY swap on by default + t.inverseX = true; + // Transposed box is {64, 128, ...}; x span is 64. Coord (5,7) swaps to (7,5), + // then x flips against the transposed span: 64 - 7 - 1 = 56. + CHECK(fold(t, 5, 7, 0, {128, 64, 1}) == mm::Coord3D{56, 5, 0}); + // The transposed-box axis lengths are unchanged by the inverse. + CHECK(transposedSize(t, {128, 64, 1}) == mm::Coord3D{64, 128, 1}); + + // No swap, only inverse Y: y reads back-to-front against the box height. + mm::TransposeModifier inv; + inv.transposeXY = false; inv.inverseY = true; + CHECK(fold(inv, 3, 0, 0, {16, 8, 1}) == mm::Coord3D{3, 7, 0}); // 8 - 0 - 1 = 7 + CHECK(fold(inv, 3, 7, 0, {16, 8, 1}) == mm::Coord3D{3, 0, 0}); // 8 - 7 - 1 = 0 +} + +// Degenerate boxes don't crash and the swap still applies to whatever extent exists. +TEST_CASE("TransposeModifier handles degenerate boxes") { + mm::TransposeModifier t; // XY swap + CHECK(transposedSize(t, {0, 0, 0}) == mm::Coord3D{0, 0, 0}); + CHECK(transposedSize(t, {1, 1, 1}) == mm::Coord3D{1, 1, 1}); + // A 1-wide, taller box transposes to wide-and-1-tall. + CHECK(transposedSize(t, {1, 8, 1}) == mm::Coord3D{8, 1, 1}); + CHECK(fold(t, 0, 0, 0, {0, 0, 0}) == mm::Coord3D{0, 0, 0}); // no crash on 0x0x0 +} diff --git a/docs/install/README.md b/web-installer/README.md similarity index 99% rename from docs/install/README.md rename to web-installer/README.md index 6fcc8ddd..6a0b59e0 100644 --- a/docs/install/README.md +++ b/web-installer/README.md @@ -318,7 +318,7 @@ for F in esp32 esp32-eth esp32s3-n16r8; do done # Drop the install page + shared picker module in place. -cp docs/install/index.html "$DIST"/ +cp web-installer/index.html "$DIST"/ cp src/ui/install-picker.js "$DIST"/ cd "$DIST" && python3 -m http.server 8000 @@ -375,6 +375,6 @@ don't ship the API. Manual setup, one-time per repo: **Settings → Pages → Source: GitHub Actions**. No deploy-from-branch — the workflow is the only producer. A separate -`docs/install/`-only Pages deploy was considered and rejected: it would +`web-installer/`-only Pages deploy was considered and rejected: it would have to re-run the same cumulative-content dance, so a docs-only deploy buys nothing. diff --git a/docs/install/config-ops.js b/web-installer/config-ops.js similarity index 100% rename from docs/install/config-ops.js rename to web-installer/config-ops.js diff --git a/docs/install/deviceModels.json b/web-installer/deviceModels.json similarity index 100% rename from docs/install/deviceModels.json rename to web-installer/deviceModels.json diff --git a/docs/install/devices.js b/web-installer/devices.js similarity index 100% rename from docs/install/devices.js rename to web-installer/devices.js diff --git a/web-installer/favicon.png b/web-installer/favicon.png new file mode 100644 index 00000000..845a0af6 Binary files /dev/null and b/web-installer/favicon.png differ diff --git a/docs/install/firmwares.json b/web-installer/firmwares.json similarity index 100% rename from docs/install/firmwares.json rename to web-installer/firmwares.json diff --git a/docs/install/improv-frame.js b/web-installer/improv-frame.js similarity index 100% rename from docs/install/improv-frame.js rename to web-installer/improv-frame.js diff --git a/docs/install/index.html b/web-installer/index.html similarity index 100% rename from docs/install/index.html rename to web-installer/index.html diff --git a/docs/install/install-orchestrator.js b/web-installer/install-orchestrator.js similarity index 99% rename from docs/install/install-orchestrator.js rename to web-installer/install-orchestrator.js index de5ec391..30297a00 100644 --- a/docs/install/install-orchestrator.js +++ b/web-installer/install-orchestrator.js @@ -978,7 +978,7 @@ export const installer = { // no deviceUrl; the host's onSuccess handler treats an // empty url as "user opted into AP fallback, walk them // through joining MM-XXXX manually" (see Step 2 in - // docs/install/index.html and the closeModal path in + // web-installer/index.html and the closeModal path in // handleSuccess). // // Note on the two "skip" shapes: `uiWaitForIp()` returns diff --git a/docs/install/install.css b/web-installer/install.css similarity index 100% rename from docs/install/install.css rename to web-installer/install.css diff --git a/docs/install/install.js b/web-installer/install.js similarity index 100% rename from docs/install/install.js rename to web-installer/install.js