diff --git a/.coderabbit.yaml b/.coderabbit.yaml
index 253bc92a..902ed172 100644
--- a/.coderabbit.yaml
+++ b/.coderabbit.yaml
@@ -68,7 +68,7 @@ reviews:
Root CMake build. Must use C++20, -Wall -Wextra -Werror. Core library is
INTERFACE (header-only). Only platform .cpp files need compilation.
- - path: "scripts/**"
+ - path: "moondeck/**"
instructions: |
MoonDeck dev console and build/test/run scripts. Scripts must resolve
- ROOT path correctly relative to their location in scripts/subdir/.
+ ROOT path correctly relative to their location in moondeck/subdir/.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 18c2fb5e..23cf38d4 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -22,7 +22,7 @@ on:
paths:
- 'src/**'
- 'esp32/**'
- - 'scripts/build/**'
+ - 'moondeck/build/**'
- 'CMakeLists.txt'
- 'library.json'
- '.github/workflows/release.yml'
@@ -79,7 +79,7 @@ jobs:
# warning on the direct ${{ }} expansion inside a shell string.
env:
TAG: ${{ inputs.tag || github.ref_name }}
- run: uv run scripts/build/verify_version.py --tag "$TAG"
+ run: uv run moondeck/ci/verify_version.py --tag "$TAG"
# The shipping firmware list, read from the generated web-installer/firmwares.json
# (projected from build_esp32.py's FIRMWARES dict, drift-guarded by
@@ -160,7 +160,7 @@ jobs:
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
- V=$(python scripts/build/compute_version.py --tag "$TAG")
+ V=$(python moondeck/build/compute_version.py --tag "$TAG")
echo "version=$V" >> "$GITHUB_OUTPUT"
- name: Build firmware
@@ -188,7 +188,7 @@ jobs:
# We run our own builder (not the action's default `idf.py build`)
# so the sdkconfig fragments and EXCLUDE_COMPONENTS go through the
# same code path as local builds. --release burns the channel tag in.
- command: python ../scripts/build/build_esp32.py --firmware ${{ matrix.firmware }} --release "${{ steps.tag.outputs.tag }}" --version "${{ steps.ver.outputs.version }}"
+ command: python ../moondeck/build/build_esp32.py --firmware ${{ matrix.firmware }} --release "${{ steps.tag.outputs.tag }}" --version "${{ steps.ver.outputs.version }}"
- name: Stage release artifacts
run: |
@@ -234,7 +234,7 @@ jobs:
# runners don't ship uv by default — install it before package_desktop.
- uses: astral-sh/setup-uv@v3
- name: Build + package macOS arm64
- run: uv run scripts/build/package_desktop.py
+ run: uv run moondeck/ci/package_desktop.py
- uses: actions/upload-artifact@v4
with:
name: desktop-macos
@@ -250,7 +250,7 @@ jobs:
# Same uv prerequisite as build-macos — see the comment there.
- uses: astral-sh/setup-uv@v3
- name: Build + package Windows x64
- run: uv run scripts/build/package_desktop.py
+ run: uv run moondeck/ci/package_desktop.py
- uses: actions/upload-artifact@v4
with:
name: desktop-windows
@@ -326,7 +326,7 @@ jobs:
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
- V=$(uv run python scripts/build/compute_version.py --tag "$TAG")
+ V=$(uv run python moondeck/build/compute_version.py --tag "$TAG")
echo "version=$V" >> "$GITHUB_OUTPUT"
- name: Generate ESP Web Tools manifests (release-asset URLs)
@@ -344,7 +344,7 @@ jobs:
# 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' web-installer/firmwares.json); do
- uv run python scripts/build/generate_manifest.py \
+ uv run python moondeck/build/generate_manifest.py \
--firmware "$F" \
--version "$V" \
--release-url "$BASE" \
@@ -567,7 +567,7 @@ jobs:
# 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"
+ uv run moondeck/docs/build_docs.py --site-dir "$RUNNER_TEMP/docs-site"
cp -r "$RUNNER_TEMP/docs-site/." pages/
ls -la pages/
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index ee68d4eb..c7f50542 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -19,7 +19,7 @@ name: Test
on:
pull_request:
paths: &test-paths
- - 'scripts/**'
+ - 'moondeck/**'
- 'web-installer/**'
- 'src/core/ImprovFrame.h'
- 'src/core/ImprovOpReassembler.h'
diff --git a/.gitignore b/.gitignore
index 1042d4c2..bd58df0f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,11 +39,11 @@
*.out
*.app
-# Build directories (anchored to root, not scripts/build/)
+# Build directories (anchored to root, not moondeck/build/)
/build/
/build-*/
-# Packaging output from scripts/build/package_desktop.py — the .tar.gz / .zip
+# Packaging output from moondeck/build/package_desktop.py — the .tar.gz / .zip
# bundles attached to GitHub releases. CI is the authoritative producer
# (release.yml on tag/main push); local runs are scratch for dev verification.
# Never commit these — they bloat the repo and can drift from source.
@@ -74,10 +74,10 @@ Testing/
.cache/
# MoonDeck state
-scripts/moondeck.json
+moondeck/moondeck.json
# Flash-event breadcrumb dropped by flash_esp32.py and consumed by MoonDeck
# on the next device refresh to link last_port → device.
-scripts/.last_flash.json
+moondeck/.last_flash.json
# Generated files
src/ui/ui_embedded.h
@@ -117,11 +117,11 @@ __pycache__/
# 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
+# Generated test inventory pages — built into the site by moondeck/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.
+# the site by moondeck/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 e8c40fec..4cd8d096 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,7 +12,7 @@ See `docs/architecture.md` for system design. This file contains only rules and
- **Industry standards, our own code.** Reach for the established, recognisable solution: the textbook *algorithm* (a DC-blocker high-pass, a Hann window, RMS, an integer-square-root) AND the textbook *name* for every variable, function, and UI control. That's *Common patterns first* applied to both domains, core and light: take the textbook approach over a clever or borrowed one. Study the prior art hard, whatever sharpens our thinking: repos, datasheets, vendor sites. Respect it, learn from it, credit it by name in the `history/` digests and per-module "Prior art" sections. Then write every line fresh against our own architecture: **carry the ideas forward, but write our own code rather than copying theirs or tracing their structure.** The method that guarantees it: spec from primary sources (ESP32 / peripheral / sensor datasheets, Espressif docs, reference standards), pin the behaviour as tests (unit + scenario), and let the worker-bee agents implement against the process ([CLAUDE.md](CLAUDE.md)) and architecture ([architecture.md](docs/architecture.md)). The result is independent by construction, not a renamed copy.
- **Minimalism means elegance, not fewest features.** Minimalism here is *consistency, reusability, no duplication, minimal memory, and the fastest hot path* — not "the smallest feature set" or "the fewest lines." A recognizable, proven construct (recursion, a textbook algorithm, a clean data structure) that ticks those boxes is *more* minimal than a hand-rolled special case, even when it's more code, because it's reusable and consistent rather than a one-off. So **reach for the established, beautiful solution**, and when you *know* a complex system will need a capability, build the cleanest *complete* version of it rather than a crippled subset that forces hacks elsewhere later (a JSON reader that can't read arrays is not "minimal" — it pushes ugliness outward). Guardrail: the construct must be *recognizable* (see *Common patterns first*), not bespoke cleverness — a contributor with general experience should recognise *what* it is within 30 seconds, even if the full *why* takes longer. Beauty and consistency win over raw line count.
- **Complexity lives in core; domain modules stay simple.** When something is genuinely hard — a recursive parser, a bounded arena, a scheduling or mapping algorithm — it belongs in the **core**, written once as the elegant standard construct (see *Minimalism means elegance*), so every **domain module** (a light effect/driver/layout/modifier, DevicesModule, …) gets to stay short and obvious by leaning on it. A domain module that is accreting parsing, buffer juggling, or clever bookkeeping is a smell: that complexity wants to move down into a core primitive the module then calls in a line or two. The test: a domain module should read like *what it does*, not *how the hard parts work* — the "how" is core's job. (Example: the device-list persistence — the recursive JSON reader is core; DevicesModule's restore is "parse, fill my array," a dozen plain lines.) This is the same coin as *Core grows slower than the domain*: core earns its size by absorbing complexity that would otherwise be duplicated, badly, across many simple modules.
-- **Core grows slower than the domain.** Adding a domain module is expected growth: a new unit of domain capability adds lines because it adds a feature, and that's fine. The **core** (domain-neutral infrastructure: `src/core/`, the platform layer, the docs that describe them) is held to a higher bar: while the system is still being built the core does grow, but each core change should buy proportionally more than a domain addition: new infrastructure that many modules use, not a one-off. A core change is suspect until that leverage is shown. Watch the ratio; core is meant to be the lean base under a wider domain, not the other way around.
+- **Core grows slower than the domain.** Adding a domain module is expected growth: a new unit of domain capability adds lines because it adds a feature, and that's fine. The **core** (domain-neutral infrastructure: `src/core/`, the platform layer, the docs that describe them) is held to a higher bar: each core change buys proportionally more than a domain addition — new infrastructure that many modules use, not a one-off. A core change is suspect until that leverage is shown. Core is the lean base under a wider domain, not the other way around. The bar is a *kind* test, not a line-count race: the domain is effectively unbounded (each light effect/driver/device model is a self-contained feature), whereas **core earns growth only by adding a recognizable, industry-standard, reusable primitive** (a streaming write, a positional read, a bounded arena, a recursive JSON reader) — never a bespoke one-off. Such a primitive is welcome *because it is the textbook construct many modules lean on*, not because it is small; a core change that only one caller needs is the smell this rule catches. (This is *Common patterns first* and *Industry standards, our own code* applied to the core/domain growth ratio.)
- **Default to subtraction.** The reflex on most changes (a bug fix, a review finding, a refactor) should be *can this remove or replace code, or land net-neutral?*, not *what do I add?* If a change only ever grows the line count and the doc count, that's the smell this rule exists to catch. Prefer removing code over adding it; a deletion that preserves behaviour is the best kind of change.
- **Continuous refactor, no hacks.** Improvement is not a scheduled phase; it happens *the moment* a hack, a divergence, or a duplicated pattern is spotted, in whatever change is already open. The bar is absolute: **never** leave a hack, a workaround, or a bespoke one-off in place because "it works for now" — the fix is the *recognisable, standard* one. So when you reach for a clever shortcut, an environment sniff, a duplicated block, a stub that papers over a broken dependency, stop and ask *what's the textbook construct here?* and do that instead. This is the union of three principles applied as a working reflex rather than a checklist: *[Common patterns first](#principles)* (use the construct a new contributor recognises in 30s), *[Industry standards, our own code](#principles)* (the textbook algorithm AND the textbook name, written fresh against our architecture), and *[Minimalism means elegance](#principles)* (consistency, reuse, no duplication, the fast hot path). What this bullet adds over those: the **timing** (on sight, continuously, not deferred to a "cleanup later" that never comes) and the **no-hacks floor** (a workaround is never the destination; if the standard fix is genuinely out of scope right now, the hack doesn't ship — it's backlogged with the standard fix named, per *[Mandatory subtraction](#process-rules)*). The product-owner-initiated counterpart, for larger restructures, is the *[Refactor for simplicity](#process-rules)* process rule; this principle is the small-scale, agent-initiated version of the same instinct.
- **No duplication, in code or docs.** Same logic in two places belongs in one shared function; same fact in two docs belongs in one place the other links to. A comment or doc paragraph that restates what the code already says is duplication too; delete it. (Reuse a recognisable shape rather than inventing one; see *Common patterns first* above.)
@@ -28,7 +28,7 @@ See `docs/architecture.md` for system design. This file contains only rules and
The design rationale for each rule below lives in [docs/architecture.md](docs/architecture.md). The one-liners here are what the agent holds in working memory.
-**Tests must pass.** Run `ctest` (unit tests) and `uv run scripts/scenario/run_scenario.py` (scenarios) before considering work complete. The Python wrapper invokes the C++ runner and persists per-target observations back to each scenario JSON (drift visibility); direct `./build/test/mm_scenarios` is fine for ad-hoc pass/fail checks but skips the JSON write-back. New core logic needs a corresponding module test. Full pipeline needs a scenario test. See [docs/testing.md](docs/testing.md) for the strategy and test inventory.
+**Tests must pass.** Run `ctest` (unit tests) and `uv run moondeck/scenario/run_scenario.py` (scenarios) before considering work complete. The Python wrapper invokes the C++ runner and persists per-target observations back to each scenario JSON (drift visibility); direct `./build/test/mm_scenarios` is fine for ad-hoc pass/fail checks but skips the JSON write-back. New core logic needs a corresponding module test. Full pipeline needs a scenario test. See [docs/testing.md](docs/testing.md) for the strategy and test inventory.
**Warnings are errors.** Build with `-Wall -Wextra -Werror`. No warning is "harmless"; if it's noise, fix it or silence it explicitly with a `-Wno-…` justified in code.
@@ -58,7 +58,7 @@ Then check the recommendation against [§ Principles](#principles) (minimalism,
**Plan before implementing.** Use `/plan` mode before every feature. Review plans for: unnecessary files, inheritance where structs suffice, modifications outside the relevant directory. Reject and regenerate bad plans. **Save every approved plan** to `docs/history/plans/` named `Plan-YYYYMMDD -
.md` (ISO-8601 date order so the directory sorts chronologically, e.g. `Plan-20260620 - Improv-as-REST.md` for 2026-06-20), as the first implementation step. The plan is the design record that complements `decisions.md` (the lesson record): the plan says what we set out to build and why; decisions.md captures what we learned doing it. **These saved plans are a reference archive for the product owner — agents WRITE a plan when creating one, but do NOT read the existing plan files for context unless the product owner explicitly points to one** (they're under the "Never automatically" rule below alongside the rest of `docs/history/`). Plans are **kept, not pruned** — they are the permanent design-intent record. When a plan's design ships (or doesn't), mark its outcome in the filename with a trailing parenthetical — `… (shipped).md` once it lands, `… (attempted, abandoned).md` if it was tried and dropped — so the directory shows at a glance what's done; an unmarked plan is still in flight.
-**Use `uv` for every Python invocation.** Never type `python` or `python3` directly; always go through `uv run` (e.g. `uv run scripts/build/build_desktop.py`, `uv run python -c "…"`). This applies to shell commands, CMake `add_custom_command` / `execute_process`, documentation examples, and anything that shells out. In CMake, resolve `find_program(UV_EXECUTABLE NAMES uv REQUIRED HINTS "$ENV{USERPROFILE}/.local/bin" "$ENV{HOME}/.local/bin")` once and use `${UV_EXECUTABLE} run python …` thereafter. Reason: uv manages the project venv and is the project standard ([scripts/MoonDeck.md](scripts/MoonDeck.md)); bare `python3` isn't on PATH on Windows (and macOS Python Launcher pops a Store prompt). If you catch yourself about to type `python`, stop and prefix with `uv run`.
+**Use `uv` for every Python invocation.** Never type `python` or `python3` directly; always go through `uv run` (e.g. `uv run moondeck/build/build_desktop.py`, `uv run python -c "…"`). This applies to shell commands, CMake `add_custom_command` / `execute_process`, documentation examples, and anything that shells out. In CMake, resolve `find_program(UV_EXECUTABLE NAMES uv REQUIRED HINTS "$ENV{USERPROFILE}/.local/bin" "$ENV{HOME}/.local/bin")` once and use `${UV_EXECUTABLE} run python …` thereafter. Reason: uv manages the project venv and is the project standard ([moondeck/MoonDeck.md](moondeck/MoonDeck.md)); bare `python3` isn't on PATH on Windows (and macOS Python Launcher pops a Store prompt). If you catch yourself about to type `python`, stop and prefix with `uv run`.
The **one exception** is `esp32/main/CMakeLists.txt`: ESP-IDF builds use IDF's bundled Python venv, not the project venv; adding uv to ESP-IDF docker would be a bigger CI lift than the portability win pays for. That file uses `find_package(Python3 REQUIRED COMPONENTS Interpreter)` and invokes `${Python3_EXECUTABLE}`, so CMake locates whichever Python IDF set up (`.venv\Scripts\python.exe` on Windows IDF, `.venv/bin/python3` on macOS/Linux IDF). The shared `src/ui/embed_ui.cmake` script takes a `PYTHON_CMD` parameter that callers pass: desktop passes `${UV_EXECUTABLE};run;python`, ESP32 passes `${Python3_EXECUTABLE}`.
@@ -68,7 +68,7 @@ The **one exception** is `esp32/main/CMakeLists.txt`: ESP-IDF builds use IDF's b
**Anti-stalling.** If a build error or test failure takes more than 2 attempts to fix, STOP. Do not rewrite surrounding files. Ask the product owner for guidance or rollback with Git and re-approach.
-**Git: only on explicit request.** Do not `git add`, `git commit`, or `git push` on your own initiative. Only execute these when the product owner explicitly asks (e.g. "commit now", "push it"). The product owner controls when changes are staged, committed, and pushed.
+**Git: only on explicit request.** Do not `git add`, `git commit`, or `git push` on your own initiative. Only execute these when the product owner explicitly asks (e.g. "commit now", "push it"). The product owner controls when changes are staged, committed, and pushed. Two workflow specifics: (1) **one combined commit per cycle** — all uncommitted branch work lands as a *single* commit, never split into standalone/partial commits (not even a small hygiene or doc change; leave those uncommitted to fold into the next combined commit), with the message's change sections grouping the areas. (2) **A verified hotfix goes straight to `main`** — a small, already-tested CI/build/manifest fix is committed directly on `main`, no feature branch or PR (the "branch first on the default branch" rule is for *feature* work that warrants review); feature work still branches.
**Gate lists are initiated by the product owner, not by agents. Never start a gate list on your own; always ask first.** The full set of gates (especially the Opus reviewer at PR-merge and the ESP32 build at commit) easily takes 10 minutes per event and burns real tokens; agents must not initiate them unprompted. When the product owner says "run pre-commit" / "commit now" / "pre-merge" / "ready to release" the relevant list below runs. Do NOT start any list automatically because you finished a feature, because tests pass, because a milestone feels reached, because completion seems imminent, or because an earlier instruction implied a sequence ending in "commit". If you finished feature work and are unsure whether the product owner wants to proceed, **ask**: "Feature work is done, should I run pre-commit, or do you want to look first?" Treat each list as a gate the product owner opens; the agent's job is finishing the work and reporting status so the product owner can decide when to open it.
@@ -107,21 +107,21 @@ The narrow safety net: "this snapshot is internally consistent."
**Conditional (run if trigger matches):**
-2. Desktop build, `cmake --build build` (zero warnings), if any file that compiles into the desktop binary changed: `src/`, `test/`, `CMakeLists.txt` (root or `test/`), `library.json`. A YAML / docs / `scripts/` / `.claude/` change cannot break the build.
+2. Desktop build, `cmake --build build` (zero warnings), if any file that compiles into the desktop binary changed: `src/`, `test/`, `CMakeLists.txt` (root or `test/`), `library.json`. A YAML / docs / `moondeck/` / `.claude/` change cannot break the build.
3. Unit tests, `ctest --output-on-failure` (all pass), same trigger as Desktop build. No build, no tests.
-4. Scenario tests, `uv run scripts/scenario/run_scenario.py` (all pass; wraps `mm_scenarios` and persists per-target `observed.` blocks back to each scenario JSON for drift tracking), same trigger as Desktop build, plus any `test/scenarios/*.json` change.
+4. Scenario tests, `uv run moondeck/scenario/run_scenario.py` (all pass; wraps `mm_scenarios` and persists per-target `observed.` blocks back to each scenario JSON for drift tracking), same trigger as Desktop build, plus any `test/scenarios/*.json` change.
5. Platform boundary, `check_platform_boundary.py`, if any file under `src/` (excluding `src/platform/`) changed.
6. ESP32 build, `build_esp32.py`, if any file under `src/` (excluding `src/platform/desktop/`), `esp32/`, `CMakeLists.txt`, or `library.json` changed.
7. KPI collection, `collect_kpi.py --commit`, if any file under `src/` changed. **The one-liner MUST include `tick:Xus(FPS:Y)` for every supported target** (PC + ESP32 today; Teensy/RPi when added). If a target's tick/FPS is missing (e.g. ESP32 wasn't monitored recently and `esp32/monitor.log` is stale), re-run a short live capture before committing, or note explicitly in the commit body why the value is absent.
-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.
+8. Device-model catalog, `check_devices.py`, fast (<1s), if `web-installer/deviceModels.json` or `moondeck/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 `moondeck/build/build_esp32.py`, `web-installer/firmwares.json`, or `moondeck/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 `moondeck/` 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/`, `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.
+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 `moondeck/` 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`, `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.
+- **Improv smoke test**, `uv run moondeck/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`, `moondeck/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.
@@ -135,7 +135,7 @@ A commit that touches *only* `.github/`, `docs/`, `README.md`, `CLAUDE.md`, or `
- **Core** (`src/core/`, `src/platform/`): e.g. `- HttpServerModule: added 409 guard to prevent overlapping OTA jobs`
- **Light domain** (`src/light/`): e.g. `- PreviewDriver: replaced strided sampling with max-pooling to fix empty frames`
- **UI** (`src/ui/`): e.g. `- app.js: auto-fit camera distance on first preview frame`
- - **Scripts / MoonDeck** (`scripts/`): e.g. `- MoonDeck: added per-scenario dropdown to scenario card`
+ - **Scripts / MoonDeck** (`moondeck/`): e.g. `- MoonDeck: added per-scenario dropdown to scenario card`
- **Tests** (`test/`): e.g. `- test_preview_driver: updated default assertions for new fps/detail/decompress values`
- **Docs / CI** (`docs/`, `README.md`, `CLAUDE.md`, `.github/`, `CMakeLists.txt`): e.g. `- README: consolidated ESP32 install info to web installer`
- **Reviews**: if any review findings were processed in this commit, one bullet per finding, prefixed by reviewer: 🐇 for CodeRabbit findings, 👾 for internal Reviewer agent findings. Each bullet states what was flagged, what was done (fixed / accepted / deferred), and, if not fixed, why. Omit if no review findings were processed.
@@ -143,7 +143,7 @@ A commit that touches *only* `.github/`, `docs/`, `README.md`, `CLAUDE.md`, or `
**Not at commit-time** (these run at PR-merge): Reviewer agent; live perf analysis + `docs/performance.md` update; documentation sync sweep; permission review.
-**On-demand reviewer.** The product owner can ask for a reviewer pass mid-branch on a single risky commit (say "run reviewer" before commit), and the agent runs it on the staged diff with the same scope as the merge-time gate. Default is fast (no reviewer); the on-demand path is a safety valve when something specific feels off.
+**Reviewer at commit-time — large commits, or on demand.** The Reviewer's home is PR-merge (it sees drift best across N commits, per gate 5 below), but a *single* large commit already hides the same drift a whole branch does, so it earns a review before it lands. Trigger: **run the Reviewer on the staged diff when the commit is large** — a rough bar is **≥ ~10 changed files spanning multiple areas** (e.g. core + light + scripts + UI), or a diff big/multi-threaded enough that "these changes each look fine alone but do they cohere?" is a real question. Same scope as the merge-time gate (gate 5). Below that bar the pass is **on demand** — the product owner says "run reviewer" before commit when a specific change feels risky. Small, single-area commits skip it (the mechanical commit gates are the net there). Either way the Reviewer only *reports*; findings are fixed, or accepted with a one-line reason in the commit body's **Reviews** section (👾), before "commit now". As with every gate list, the product owner initiates — the agent finishing a large diff should *ask* "this is large, run the Reviewer first?", not launch it unprompted.
#### Event 2: PR merge into `main`
@@ -241,7 +241,7 @@ The project uses Claude Code agents in defined roles. The user is the **Product
|--|-------|-------|-------|------|
| 🤖 | **Architect** | Opus | System design | Reviews against architecture, designs components, validates boundaries |
| 👽 | **Developer** | Sonnet | Implementation | Writes code in worktrees, follows all rules, one step at a time |
-| 👾 | **Reviewer** | Fable when available, otherwise Opus | Pre-merge check | Runs at PR merge over the whole branch diff (Event 2, gate 5); available on-demand pre-commit on the staged diff when the product owner asks. Complements CodeRabbit (which handles line-level bugs in the PR). |
+| 👾 | **Reviewer** | **Fable** (Opus only if Fable is unavailable) | Pre-merge check | Runs at PR merge over the whole branch diff (Event 2, gate 5), and pre-commit on the staged diff when the commit is large or the product owner asks (see *Reviewer at commit-time*). The model is fixed, not a per-run choice. Complements CodeRabbit (which handles line-level bugs in the PR). |
| 🛸 | **Tester** | Sonnet | Verification | Writes tests, verifies architectural rules in code |
| 💀 | **Runner** | Haiku | Quick checks | Runs MoonDeck scripts, platform boundary checks, build verification |
@@ -249,6 +249,6 @@ Agents work in parallel on independent steps. Agents never commit; only the prod
## Build
-How to build, flash, run, monitor, and check the project for every target: [docs/building.md](docs/building.md). Per-script reference: [scripts/MoonDeck.md](scripts/MoonDeck.md).
+How to build, flash, run, monitor, and check the project for every target: [docs/building.md](docs/building.md). Per-script reference: [moondeck/MoonDeck.md](moondeck/MoonDeck.md).
-Agents use the CLI (`uv run scripts//.py`); humans typically use MoonDeck (`uv run scripts/moondeck.py`, port 8420). Same scripts under both. Every gate in [Lifecycle Events](#lifecycle-events) ultimately invokes one of these scripts.
+Agents use the CLI (`uv run moondeck//.py`); humans typically use MoonDeck (`uv run moondeck/moondeck.py`, port 8420). Same scripts under both. Every gate in [Lifecycle Events](#lifecycle-events) ultimately invokes one of these scripts.
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 4c598345..3c7a4b2f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -38,7 +38,7 @@ else()
add_compile_options(-Wall -Wextra -Werror)
endif()
-# `uv` is the project's Python launcher (see CLAUDE.md / scripts/MoonDeck.md).
+# `uv` is the project's Python launcher (see CLAUDE.md / moondeck/MoonDeck.md).
# The build invokes Python helpers for build_info.h generation and UI embedding;
# resolving them through `uv run python …` keeps Windows (where `python3` isn't
# on PATH) on the same path as macOS / Linux without a `find_package(Python3)`
@@ -54,6 +54,8 @@ add_library(mm_core STATIC
src/core/Control.cpp
src/core/HttpServerModule.cpp
src/core/FilesystemModule.cpp
+ src/core/FileManagerModule.cpp
+ src/core/MqttModule.cpp
src/core/Scheduler.cpp
src/core/moonlive/MoonLive.cpp
src/core/moonlive/MoonLiveCompiler.cpp
@@ -77,8 +79,8 @@ target_link_libraries(mm_platform PUBLIC $<$:ws2_32>)
# Generate build_info.h from library.json (carries version, build date, board name).
add_custom_command(
OUTPUT ${CMAKE_SOURCE_DIR}/src/core/build_info.h
- COMMAND ${UV_EXECUTABLE} run python ${CMAKE_SOURCE_DIR}/scripts/build/generate_build_info.py
- DEPENDS ${CMAKE_SOURCE_DIR}/library.json ${CMAKE_SOURCE_DIR}/scripts/build/generate_build_info.py
+ COMMAND ${UV_EXECUTABLE} run python ${CMAKE_SOURCE_DIR}/moondeck/build/generate_build_info.py
+ DEPENDS ${CMAKE_SOURCE_DIR}/library.json ${CMAKE_SOURCE_DIR}/moondeck/build/generate_build_info.py
COMMENT "Generating build_info.h"
)
add_custom_target(build_info_gen DEPENDS ${CMAKE_SOURCE_DIR}/src/core/build_info.h)
diff --git a/README.md b/README.md
index 15fca3fe..0f3e1900 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ Drive large LED installations and DMX lighting from ESP32, Teensy, Raspberry Pi,
📦 **Release + downloads:** [latest release](https://github.com/MoonModules/projectMM/releases/latest)
-🛠️ **Building / hacking on it?** [MoonDeck](scripts/MoonDeck.md), our browser-based dev console (build · flash · test · live device discovery), comes in the repo.
+🛠️ **Building / hacking on it?** [MoonDeck](moondeck/MoonDeck.md), our browser-based dev console (build · flash · test · live device discovery), comes in the repo.
Open Chrome or Edge, plug in your device, and you'll see lights in under a minute.
@@ -32,6 +32,10 @@ If you like projectMM, give it a ⭐️, fork it, or open an issue or pull reque
🎵 **Audio-reactive**: an I²S microphone drives a 16-band FFT spectrum + sound level, consumed by audio-reactive effects — all built fresh from the mic datasheet and textbook DSP.
+🏠 **Home-automation control**: a device joins Homebridge (and any MQTT hub) over a dependency-free MQTT 3.1.1 client — on/off, brightness, and a HomeKit colour wheel that picks the nearest palette. See [the MQTT module docs](docs/moonmodules/core/ui/ui.md#mqtt).
+
+📁 **On-device File Manager**: browse and edit the device filesystem from the browser — a lazy folder tree with an inline editor, drag-drop upload, and create/delete, plus [firmware upload OTA](docs/moonmodules/core/ui/ui.md#firmware-update) (flash a `.bin` over the LAN, no USB). See [the File Manager docs](docs/moonmodules/core/ui/ui.md#file-manager).
+
🛡️ **Robust to any input**: add, delete, replace, or reconfigure any module in any order, at any grid size, and the device keeps running — degraded or idle, never crashed. Every crash that's ever found becomes a regression test, so it stays fixed.
🖥️ **One source tree, many targets**: the same code runs on ESP32, Teensy, Raspberry Pi, and macOS / Windows / Linux.
@@ -108,10 +112,10 @@ You need [uv](https://docs.astral.sh/uv/) (Python launcher), CMake 3.20+, and a
Once prerequisites are in place, launch MoonDeck, the browser-based dev console:
```sh
-uv run scripts/moondeck.py
+uv run moondeck/moondeck.py
```
-Open `http://localhost:8420`: PC tab to build / run / test, ESP32 tab to flash, Live tab to discover devices. Full per-command reference: [scripts/MoonDeck.md](scripts/MoonDeck.md).
+Open `http://localhost:8420`: PC tab to build / run / test, ESP32 tab to flash, Live tab to discover devices. Full per-command reference: [moondeck/MoonDeck.md](moondeck/MoonDeck.md).

diff --git a/docs/architecture.md b/docs/architecture.md
index 7bd41030..b6f4649e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -109,7 +109,7 @@ 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 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.
+Each MoonModule has two documentation surfaces under `docs/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
@@ -218,7 +218,7 @@ Only abstract what you actually need. Currently:
Abstractions are added when a concrete implementation needs them, not pre-designed.
-**Platform boundary (hard rule).** All `#ifdef`, `#if defined`, platform-specific `#include`s, and hardware API calls live exclusively in `src/platform/`. Everything outside `src/platform/` compiles on every target without modification. Compile-time platform branching uses `if constexpr` on `platform_config.h` flags, never a preprocessor `#ifdef`. The boundary is enforced by [`scripts/check/check_platform_boundary.py`](../scripts/check/check_platform_boundary.py), a commit gate (see [CLAUDE.md § Lifecycle Events](../CLAUDE.md#lifecycle-events)).
+**Platform boundary (hard rule).** All `#ifdef`, `#if defined`, platform-specific `#include`s, and hardware API calls live exclusively in `src/platform/`. Everything outside `src/platform/` compiles on every target without modification. Compile-time platform branching uses `if constexpr` on `platform_config.h` flags, never a preprocessor `#ifdef`. The boundary is enforced by [`moondeck/check/check_platform_boundary.py`](../moondeck/check/check_platform_boundary.py), a commit gate (see [CLAUDE.md § Lifecycle Events](../CLAUDE.md#lifecycle-events)).
## Firmware vs deviceModel vs board
diff --git a/docs/assets/boards/shelly.jpg b/docs/assets/boards/shelly.jpg
new file mode 100644
index 00000000..9cc52105
Binary files /dev/null and b/docs/assets/boards/shelly.jpg differ
diff --git a/docs/assets/extra.css b/docs/assets/extra.css
index b632901f..e4dbf88f 100644
--- a/docs/assets/extra.css
+++ b/docs/assets/extra.css
@@ -1,5 +1,5 @@
/* Catalog tables (effects / modifiers / layouts / drivers), rendered from prose
- ### blocks by scripts/docs/mkdocs_hooks.py. Give the four columns sensible
+ ### blocks by moondeck/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".
diff --git a/docs/backlog/README.md b/docs/backlog/README.md
index 97b8d199..65be5733 100644
--- a/docs/backlog/README.md
+++ b/docs/backlog/README.md
@@ -1,6 +1,6 @@
# Backlog — index
-The forward-looking half of the docs (the backward-looking half is [`../history/`](../history/)). This folder is **not** present-tense and agents don't read it automatically — only when planning new work. See [CLAUDE.md § Documentation](../../CLAUDE.md) for how `backlog/` and `history/` relate.
+The forward-looking half of the docs (the backward-looking half is [`../history/`](../history/README.md)). This folder is **not** present-tense and agents don't read it automatically — only when planning new work. See [CLAUDE.md § Documentation](../../CLAUDE.md) for how `backlog/` and `history/` relate.
This README is the **landing page**: the rest of the system links here, not into individual items, so the present-tense docs stay present-tense.
@@ -42,7 +42,7 @@ A map of everything in the three files, by theme.
## In-flight draft specs
-A spec for a not-yet-built module can live here as a plain draft `.md` (alongside the design studies below) until the module ships — at which point its final spec is written in [`../moonmodules/`](../moonmodules/) and the draft is deleted. None are in flight right now.
+A spec for a not-yet-built module can live here as a plain draft `.md` (alongside the design studies below) until the module ships — at which point its final spec is written under `../moonmodules/` (e.g. [drivers](../moonmodules/light/drivers/drivers.md)) and the draft is deleted. None are in flight right now.
## Design studies
diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md
index b8b5d696..475da5ef 100644
--- a/docs/backlog/backlog-core.md
+++ b/docs/backlog/backlog-core.md
@@ -34,6 +34,26 @@ Full design + the reasoned transport split: [Plan-20260629 — UDP device discov
## ESP32 performance and memory
+### Flash budget — the 4 MB classic ESP32 is the ceiling (investigation)
+
+The binary has grown ~1.4 → ~1.48 MB as effects, audio sync, IR, and Ethernet landed. Per-board headroom against the app (OTA) partition slot:
+
+| Board | app slot | binary | used |
+|---|---|---|---|
+| **classic esp32 (4 MB)** | 1.75 MB | ~1.48 MB | **~84 %** ⚠️ |
+| esp32s3-n8r8 (8 MB) | 3.00 MB | ~1.48 MB | ~49 % |
+| 16 MB boards | 4.00 MB | ~1.48 MB | ~37 % |
+
+Only the **4 MB classic** is tight (the partition comment itself notes "~200 KB headroom"); the 8/16 MB boards have years of room. So this is a *classic-ESP32-only* constraint, not a global one — the fix should shrink the small-flash build without touching the flagship boards.
+
+Levers, roughly by payoff-per-effort:
+
+1. **`-Os` for the size-bound builds.** The ESP32 build currently runs the IDF default optimization (`CONFIG_COMPILER_OPTIMIZATION_DEBUG`, ~`-Og`), **not** `CONFIG_COMPILER_OPTIMIZATION_SIZE` (`-Os`). Setting size-opt on the classic (and any small-flash) firmware typically buys 5–15 % flash for free — measure the tick/FPS delta, since `-Os` can cost a little hot-path speed; if it does, gate it to the flash-bound firmwares only, not the S3/P4 where headroom is fine.
+2. **`MM_MINIMAL` feature profile for the 4 MB target.** The classic board doesn't need every effect/driver compiled in. A build profile that compiles out heavy optional modules (the same `firmware_cmake_args()` seam the eth/wifi gating already uses) keeps the flagship boards full-featured while the small board ships a curated subset — the standard "small board, smaller build" pattern.
+3. **Repartition the 4 MB classic.** If A/B OTA isn't required on the classic, a single-app-slot layout nearly doubles the app ceiling (1.75 → ~3.5 MB). Trade-off: OTA loses its rollback slot. Decide per-board, not globally.
+
+Start with (1) — it's a one-line sdkconfig change with a measurable payoff and no code churn; (2)/(3) only if (1) plus normal growth still crowds the classic. The UI embed is already gzipped (`app.js` ~140 KB → ~40 KB), so UI growth is cheap in flash; the pressure is C++ `.text`.
+
### E1.31 multicast receive (IGMP join)
NetworkReceiveEffect accepts E1.31 via unicast only — the same scope MoonLight ships. Multicast senders address the per-universe group `239.255.{universe_hi}.{universe_lo}`, which a receiver must join via IGMP; the platform `UdpSocket` has no `IP_ADD_MEMBERSHIP` support yet (lwIP `setsockopt` on ESP32, plain `setsockopt` on desktop, plus a join-per-accepted-universe bookkeeping question). Add when a multicast-only sender actually shows up on a bench; until then the spec documents "point sACN senders at the device's IP".
@@ -49,7 +69,7 @@ This determines the practical LED limit for WiFi-only boards. Until the `sdkconf
### Network round-trip test — drop/reorder measurement (deferred)
-`scripts/scenario/run_network_roundtrip.py` measures PC→device→PC **latency and jitter** per protocol (ArtNet/E1.31/DDP) by timing how long a sent colour takes to appear in the device's preview stream. It deliberately does **not** measure per-frame **drops or reorder**, because the path can't track individual frames cleanly: `NetworkSendDriver` re-clocks at its own fps (decoupled from receive) and `NetworkReceiveEffect` holds-last-frame, so frames don't pass through 1:1 — a sequence number embedded in frame N may be re-sent 0, 1, or several times downstream. The min/median/max spread the test already reports *is* the jitter signal (it surfaced multi-second outliers on the classic ESP32). To measure true drop/reorder, the firmware would need a sequence-faithful echo path (e.g. a `NetworkReceiveEffect` echo mode that re-emits each received frame 1:1 back to the sender, bypassing the fps re-clock), then the PC could match sent↔received sequence numbers. The test's docstring lists this under "extend later" alongside per-frame sequence matching and the device→device chain.
+`moondeck/scenario/run_network_roundtrip.py` measures PC→device→PC **latency and jitter** per protocol (ArtNet/E1.31/DDP) by timing how long a sent colour takes to appear in the device's preview stream. It deliberately does **not** measure per-frame **drops or reorder**, because the path can't track individual frames cleanly: `NetworkSendDriver` re-clocks at its own fps (decoupled from receive) and `NetworkReceiveEffect` holds-last-frame, so frames don't pass through 1:1 — a sequence number embedded in frame N may be re-sent 0, 1, or several times downstream. The min/median/max spread the test already reports *is* the jitter signal (it surfaced multi-second outliers on the classic ESP32). To measure true drop/reorder, the firmware would need a sequence-faithful echo path (e.g. a `NetworkReceiveEffect` echo mode that re-emits each received frame 1:1 back to the sender, bypassing the fps re-clock), then the PC could match sent↔received sequence numbers. The test's docstring lists this under "extend later" alongside per-frame sequence matching and the device→device chain.
### Async ArtNet send — decouple the wire from the render tick (PSRAM-only)
@@ -74,7 +94,7 @@ On Olimex ESP32-Gateway flashed with `esp32-eth`, Ethernet sometimes takes **a m
What we know:
- `build/esp32-esp32-eth/sdkconfig` and `build/esp32-esp32-eth-wifi/sdkconfig` are **byte-identical** (3,617 lines each, `cmp -s` confirms). So lwIP buffer pools, DHCP timeouts, and Ethernet driver settings are the same.
- Same hardware (Olimex ESP32-Gateway Rev G), same RMII pin/clock config (`EMAC_CLK_OUT` on GPIO17), same `ethInit()` code in `src/platform/esp32/platform_esp32.cpp`.
-- The only difference at link time: `esp32-eth` passes `EXCLUDE_COMPONENTS=esp_wifi;wpa_supplicant;esp_coex` to ESP-IDF (see `scripts/build/build_esp32.py:31`).
+- The only difference at link time: `esp32-eth` passes `EXCLUDE_COMPONENTS=esp_wifi;wpa_supplicant;esp_coex` to ESP-IDF (see `moondeck/build/build_esp32.py:31`).
- `esp_coex` (WiFi/Bluetooth coexistence) was an early hypothesis: even though Ethernet doesn't share the radio, `esp_coex`'s init might warm a shared clock path that helps Ethernet auto-negotiation, and the eth-only build excludes it. **Disproven — see below.**
**Firmware is ruled out (the evidence is contradictory across reboots with the *same* build, which by itself proves build-independence).** Over one session, on the same Olimex + cable:
@@ -96,10 +116,10 @@ Bottom line: intermittent, build-independent, reset-correlated → a hardware/PH
### MoonDeck doc-asset endpoint hardening (backlog)
-`scripts/moondeck.py::_serve_doc_asset` accepts any ROOT-relative path and serves the file. Path traversal *is* blocked (`asset_path.relative_to(ROOT.resolve())`), but inside the repo any file is served — including local-only artefacts like `scripts/build/wifi_credentials.json` if present. MoonDeck binds to all interfaces by design (the existing comment in `main()` explicitly enables LAN reach), so anyone on the LAN can hit the endpoint.
+`moondeck/moondeck.py::_serve_doc_asset` accepts any ROOT-relative path and serves the file. Path traversal *is* blocked (`asset_path.relative_to(ROOT.resolve())`), but inside the repo any file is served — including local-only artefacts like `moondeck/build/wifi_credentials.json` if present. MoonDeck binds to all interfaces by design (the existing comment in `main()` explicitly enables LAN reach), so anyone on the LAN can hit the endpoint.
Two improvements when this matters:
-- **Subdirectory whitelist** — only serve under `docs/` (and image asset paths the markdown renderer needs). Reject `scripts/build/wifi_credentials.json` etc. with 403.
+- **Subdirectory whitelist** — only serve under `docs/` (and image asset paths the markdown renderer needs). Reject `moondeck/build/wifi_credentials.json` etc. with 403.
- **Extension whitelist** — only image / CSS / JS mime types via a small allowlist.
- **Optional bind-to-localhost flag** — `--bind 127.0.0.1` for users who don't want LAN reachability. Default stays "" (all interfaces) since the LAN-reach is the documented design.
@@ -254,7 +274,7 @@ What to build (~4 h):
- **Platform layer** — implement `ota_begin`/`ota_write`/`ota_end` in [platform_esp32.cpp](../src/platform/esp32/platform_esp32_ota.cpp) wrapping `esp_ota_*` directly (already declared in [platform.h](../src/platform/platform.h), currently bypassed by `esp_https_ota_perform`).
- **HTTP route** — `POST /api/firmware` in [HttpServerModule.cpp](../src/core/HttpServerModule.cpp); streams `conn.read(chunkBuf, 4096)` → `ota_write`; shares `g_otaStatus`/`g_otaPct` with the URL path.
- **`device_ip` control on SystemModule** — lets a script discover the device without mDNS.
-- **`scripts/build/flash_over_network.py`** — finds the latest `.bin` in `build/esp32-/`, POSTs it, polls for `rebooting`.
+- **`moondeck/build/flash_over_network.py`** — finds the latest `.bin` in `build/esp32-/`, POSTs it, polls for `rebooting`.
- **UI file-upload affordance** — ` ` in a `` expander on the Firmware card.
### HTTP file serving blocks the render tick (backlog)
@@ -269,7 +289,7 @@ What to build (~4 h):
- **UI page load time** — scenario step measuring HTTP response time for `/`, `/api/state`, `/api/system` via the live runner. Verifies acceptable load time on ESP32.
- **Module teardown memory** — scenario that tears down all modules and verifies heap returns to pre-setup baseline. Confirms no lifecycle leaks.
- **JavaScript test harness** — `vitest` + `jsdom` for the browser UI: pure helpers in `install-picker.js` (`isCompatible`, `parseFirmwaresFromAssets`, `relativeTime`) **and `app.js`'s conditional-control DOM logic** (`syncVisibleControls` — reconciles which control rows are rendered when a `hidden` flag flips). The C++/backend half of conditional controls IS unit-tested (`conditional_controls.h` + per-module tests pin the binding + `hidden` flag), but the **UI re-render half is not** — `syncVisibleControls` was the source of a real re-render-loop freeze (Network static-IP toggle) caught only on hardware. A `jsdom` test that builds a card, flips a control's `hidden`, runs the reconcile, and asserts the right rows appear/disappear (and that it converges — the unchanged→no-op fast path) would have caught it. **Attempted and reverted (2026-06-17):** stood up vitest + 13 passing tests for the install-picker pure helpers, but the high-value half (`syncVisibleControls`) needs either an `app.js` module seam or extracting its reconcile logic into a separate served `.js` (6 embed/route wiring edits for a firmware-served file). Judged not worth adding a whole Node/npm toolchain to a C++/Python repo to test ~3 small pure functions; the toolchain earns its place only once the `syncVisibleControls` DOM test (and a real body of JS logic) lands with it. **Do it as its own focused branch**, deciding the app.js seam first (it's already `type="module"`, so extracting `reconcileControlRows` into a served file — wired through `embed_ui.cmake` + the two HttpServerModule routes like the other UI .js — is the clean shape). Pure-helper `_test` exports + the reconcile extraction are the two pieces; both were prototyped in that reverted attempt.
-- **Browser-level Improv automation** (deferred) — `scripts/build/improv_smoke_test.py` (added 2026-06-03) exercises the device-side Improv listener over plain serial; what's missing is the browser-side equivalent — Playwright driving Chrome's Web Serial, clicking through ESP Web Tools' install modal, filling the WiFi creds form, asserting `PROVISIONED`. Catches "ESP Web Tools changed its Improv handling in a way that broke our manifest format" failures the serial-only smoke test can't see. Hard to set up reliably (headless Chrome with Web Serial is finicky, needs a wired ESP32 in CI). Pick this up if a regression in the browser flow ever escapes the manual dev-environment test (preview_installer flash-ready mode at ).
+- **Browser-level Improv automation** (deferred) — `moondeck/build/improv_smoke_test.py` (added 2026-06-03) exercises the device-side Improv listener over plain serial; what's missing is the browser-side equivalent — Playwright driving Chrome's Web Serial, clicking through ESP Web Tools' install modal, filling the WiFi creds form, asserting `PROVISIONED`. Catches "ESP Web Tools changed its Improv handling in a way that broke our manifest format" failures the serial-only smoke test can't see. Hard to set up reliably (headless Chrome with Web Serial is finicky, needs a wired ESP32 in CI). Pick this up if a regression in the browser flow ever escapes the manual dev-environment test (preview_installer flash-ready mode at ).
### Live full-suite run leaks state between scenarios (test infra)
@@ -293,7 +313,7 @@ The build IDF is `v6.1-dev-399-gd1b91b79b5`, a dev-branch snapshot (2025-11-05)
### Three-level device model: MCU → Board → Device (config provenance)
-The model itself is now a shipped design — see architecture.md § Config provenance (the three levels + the `txPowerSetting` example + "default only at the level that fixes it"). The catalog that carries it is `install/deviceModels.json` (schema). **MoonDeck device-profile save/restore is shipped** — capture a device's pin/peripheral config (`/api/save-profile`) and re-apply it after a reflash or to a clone (`/api/apply-profile`), stored per-device in `moondeck.json`. The remaining forward-looking pieces — a `devices.json`/MCU-layer split and annotated-pin images — stay gated by the sequencing rule (no catalog field ahead of a consumer).
+The model itself is now a shipped design — see architecture.md § Config provenance (the three levels + the `txPowerSetting` example + "default only at the level that fixes it"). The catalog that carries it is `web-installer/deviceModels.json` (schema). The remaining forward-looking pieces — a `devices.json`/MCU-layer split and annotated-pin images — stay gated by the sequencing rule (no catalog field ahead of a consumer).
### Persistence overlay: partial-save / schema-change audit (backlog)
@@ -346,6 +366,16 @@ Forward-looking companion to the shipped UI spec, [moonmodules/core/ui/ui.md](..
- Core affinity badge (C0/C1) — only meaningful when core pinning lands
- Module `category()` field — taxonomy beyond `role()` for the picker (decision: derive from `role()` for now)
+### File Manager follow-ups
+
+The shipped File Manager (see [ui.md](../moonmodules/core/ui/ui.md)) is a lazy expand/collapse tree over `/api/dir` + a size-capped text editor over `/api/file`, with drag-drop upload (tier 1) + per-file download + a filesystem-usage bar. Deferred capabilities, each self-contained:
+
+- **Upload — recursive folder tier.** Single-file upload of **any size** streams to the file (`fsWriteStream`, binary-safe, `kUploadMax` + free-space guarded) and downloads stream back (`fsReadAt`), so text/config/binary all work. Remaining: **multi-file / recursive folder drops** — client-side `mkdir` + walk via `dataTransfer.items` `webkitGetAsEntry()`.
+- **Download — folder as `.zip`.** Per-file download streams (`` on `/api/file`). A folder download needs the browser to walk `/api/dir` recursively, fetch each file, and build a `.zip` client-side — which means bundling a zip library into `app.js`. That's a permanent app.js size bump, and app.js is embedded in firmware, so it weighs on the flash budget (see the flash-budget item above). Gate on real demand; symmetric with the folder-upload tier.
+- **Last-modified dates.** Needs a time source (NTP) + LittleFS mtime storage; the tree is name + size until both land. Backlogged with the NTP work.
+- **`.ml` syntax highlighting in the editor.** MoonLive source wants *highlighting* (a colour layer over the textarea), not the JSON-style reformat — a bigger editor change (a highlight overlay or a small tokenizer), added when MoonLive `.ml` files land on disk. The editor already has an extension seam (`fmPrettify`) for the reformat case; highlighting is the separate, larger tier.
+- **Keyboard + screen-reader access for the tree.** The `fm-row` tree entries are mouse-only today (click to select/expand/open). Making them keyboard-operable (focusable, Enter/Space activation) with ARIA tree semantics (`role="treeitem"`, `aria-expanded` from `isOpen`, `aria-level`) is a real accessibility win but adds JS that lands in the firmware-embedded `app.js` (flash cost). Do it when a11y is a stated goal, together across the whole generic UI, not just this panel.
+
### Open design questions
These don't block the shipped baseline but should be answered before 1.0:
diff --git a/docs/backlog/docs-system-overhaul.md b/docs/backlog/docs-system-overhaul.md
index 9de1c713..32a824a8 100644
--- a/docs/backlog/docs-system-overhaul.md
+++ b/docs/backlog/docs-system-overhaul.md
@@ -8,7 +8,7 @@ Follows the *Refactor for simplicity* process rule: alternatives enumerated, gai
- **~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.
+- **One generation loop already works and proves the model:** `moondeck/docs/generate_test_docs.py` reads test metadata (`// @module` tags in `test/unit/*.cpp`, JSON fields in `test/scenarios/*.json`) and emits `docs/tests/{unit,scenario}-tests.md`. The same parser (`_test_metadata.py`) feeds MoonDeck's `/api/tests` UI. Source of truth = the test file. This is the pattern to extend, not replace.
- **Duplication hotspots** — each fact lives in N places, changing one forces the others:
| Fact | Lives in | Sync today |
@@ -54,7 +54,7 @@ Rejected: **classic Doxygen HTML** (separate site, dated UI, not integrated), **
- 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)*
+### Phase 2 — Fold generated tests into the site + surface them to users — *DONE (moondeck/docs/mkdocs_hooks.py)*
*As shipped: the two inventory pages are generated at build time (never committed). Each effect/modifier keeps a compact card (heading, GIF, params, source link) with a one-line `[Tests]` link into its inventory section — an early attempt to expand that link into the full inline case list bloated the cards and was reverted; tests are a link, not a dump.*
- Run the existing test-doc generation *at site-build time* via `mkdocs-gen-files` (stop committing `docs/tests/*.md` — the 25K generated words leave the repo, generated fresh each build). Kills the "forgot to regenerate" drift class entirely.
@@ -100,5 +100,5 @@ Prove each on **one module**, then sweep. Order by leverage:
## Source
-- Investigation basis: `scripts/docs/generate_test_docs.py`, `scripts/check/check_specs.py`, `.github/workflows/release.yml` (Pages deploy), the `docs/` tree.
+- Investigation basis: `moondeck/docs/generate_test_docs.py`, `moondeck/check/check_specs.py`, `.github/workflows/release.yml` (Pages deploy), the `docs/` tree.
- Prior art / tooling: [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/), [pymdownx snippets](https://facelessuser.github.io/pymdown-extensions/extensions/snippets/), [Doxide](https://github.com/lawmurray/doxide).
diff --git a/docs/backlog/folder-structure-proposal.md b/docs/backlog/folder-structure-proposal.md
index fb7cb191..142dff84 100644
--- a/docs/backlog/folder-structure-proposal.md
+++ b/docs/backlog/folder-structure-proposal.md
@@ -88,7 +88,7 @@ test/scenarios/light/{effects,layouts,drivers,…}/ (mirror)
| `docs/moonmodules/` | per-library pages (`effects_.md`) with compact rows + the `check_specs.py` rewrite; delete the ~21 per-module effect `.md`s. | Medium | **Migration Stage 2** (already planned) |
| `src/` | **unchanged** (already `domain/type`, flat). | none | — |
-**Not reshaped** (correctly orthogonal): `test/js`, `test/python` (host-side; test scripts/installer, not modules), `src/platform/{desktop,esp32}` (platform split, already consistent), `src/{core,light}/moonlive` (feature sub-tree).
+**Not reshaped** (correctly orthogonal): `test/js`, `test/python` (host-side; test moondeck/installer, not modules), `src/platform/{desktop,esp32}` (platform split, already consistent), `src/{core,light}/moonlive` (feature sub-tree).
## Sequencing
diff --git a/docs/backlog/leddriver-analysis-top-down.md b/docs/backlog/leddriver-analysis-top-down.md
index 31876841..f2527398 100644
--- a/docs/backlog/leddriver-analysis-top-down.md
+++ b/docs/backlog/leddriver-analysis-top-down.md
@@ -392,7 +392,7 @@ Each platform reaches hello-world when:
1. A 100-pixel solid-color test frame renders on a real strip.
2. The on-board loopback test (5.2) passes on real hardware.
3. The fx2lafw cross-check (5.3) passes — same bytes, same reset gap.
-4. The fps/jitter KPI line (per [collect_kpi.py](../../scripts/collect/collect_kpi.py)) is captured and stored.
+4. The fps/jitter KPI line (per [collect_kpi.py](../../moondeck/check/collect_kpi.py)) is captured and stored.
5. (ESP32 only) the test still passes with WiFi associated and a packet flood running.
## 7. Product-owner decisions
diff --git a/docs/backlog/rename-scripts-to-moondeck.md b/docs/backlog/rename-scripts-to-moondeck.md
deleted file mode 100644
index 03c676ce..00000000
--- a/docs/backlog/rename-scripts-to-moondeck.md
+++ /dev/null
@@ -1,49 +0,0 @@
-# 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": "/.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 /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/backlog/rename-to-moonlight.md b/docs/backlog/rename-to-moonlight.md
index 2ac14878..3d9ac392 100644
--- a/docs/backlog/rename-to-moonlight.md
+++ b/docs/backlog/rename-to-moonlight.md
@@ -11,7 +11,7 @@ The hard part is the **name collision**: while the move is in progress, `MoonMod
## Blast radius (measured)
-- **`projectMM` → `MoonLight`:** ~579 references across ~135 files. Concentrated in `docs/` (history/plans, moonmodules specs, install), then `src/` (core, light/effects, ui), `scripts/build`, and `test/`.
+- **`projectMM` → `MoonLight`:** ~579 references across ~135 files. Concentrated in `docs/` (history/plans, moonmodules specs, install), then `src/` (core, light/effects, ui), `moondeck/build`, and `test/`.
- **Predecessor links to repoint** (`github.com/MoonModules/MoonLight` → `github.com/ewowi/MoonLight`): ~23 URLs, all in `docs/` prior-art / history sections.
- **`MoonLight` as prior-art prose** (NOT the new product name): ~25 mentions in `docs/history` + light specs — these must NOT be swept into the rename; they describe the predecessor.
- **`MoonLive`** (the on-device scripting engine, 29 files): a *different* name, NOT part of this rename. Any sweep must exclude it.
@@ -20,7 +20,7 @@ The hard part is the **name collision**: while the move is in progress, `MoonMod
These are the ones that break running devices, OTA, or the installer if mistimed — they change **at the switch**, not before:
-- **Binary name** `projectMM.bin` (`library.json`, `scripts/build/flash_esp32.py`, `generate_manifest.py`, `package_desktop.py`) — renaming changes OTA asset names and the web-installer manifest; old + new must line up with the release that ships under the new repo.
+- **Binary name** `projectMM.bin` (`library.json`, `moondeck/build/flash_esp32.py`, `generate_manifest.py`, `package_desktop.py`) — renaming changes OTA asset names and the web-installer manifest; old + new must line up with the release that ships under the new repo.
- **OTA download URLs** `github.com/MoonModules/projectMM/releases/...` (`docs/install/install.js`, `FirmwareUpdateModule`) — deployed devices fetch updates from here; the URL only resolves to the new repo after the repo rename.
- **mDNS / device identity** the `MM-XXXX` hostname prefix (`SystemModule.h`) — devices on the LAN announce as `MM-….local`. Changing the prefix (e.g. to `ML-`) renames every device's network identity; deliberately deferred (own decision: keep `MM-` or move to `ML-`).
- **Repo URL** in docs/READMEs/`package_desktop.py` source links.
@@ -62,11 +62,11 @@ Decoupling and groundwork that's safe while both repos still hold their current
- **npm/pip/package identifiers:** none exist (no `package.json`, no `pyproject.toml`/`setup.py`) — nothing to rename.
3. **Centralise the name** — ✅ **investigated, decided: skip *for the rename*; the sweep (step 4) covers it. The constant belongs to the library direction, not here (see note).** Centralising *to shrink the switch* doesn't pay: the centralisable runtime-identity is tiny (3 C++ network wire-strings — ArtNet/E1.31 source-name + CID — plus the boot printf), those are fixed-length protocol fields whose `memcpy(…, 9)`-style copies need rewriting *regardless*, and a constant added for a one-time event sits unused afterward — the single-purpose abstraction *Default to subtraction* / *Core grows slower than the domain* say not to add. The mechanical sweep (step 4) flips every literal — wire strings, the golden-vector tests that assert them, CMake target, UI HTML, repo URL — in **one auditable commit** where the assertion and the wire bytes move in lockstep (better than a constant, which would split them). So no constant; the sweep is the centralisation. *(Step-2 decisions stand: no value changes until the switch.)*
- > **Could we reuse `library.json`'s `name` now where a literal sits (subtraction, not a new constant)?** Surveyed `scripts/` for it — verdict: **no genuine low-hanging fruit.** ~95% of `projectMM` literals there are the **binary name** (`build/…/projectMM`, `.bin`, `.exe`, `.log`, `pkill projectMM`, crash `.ips`) which must track the **CMake target**, not `library.json` (wiring them to the product name would break the path to the file on disk); plus one **wire literal** (`_net_probe.py` ArtNet source-name, must byte-match the device) and ~15 prose/docstrings. The only product-name candidates — `generate_manifest.py`'s manifest `name`/`home_assistant_domain` — must *stay* `projectMM` today (Step 2), don't currently read `library.json`, and flip alongside `library.json` in the sweep anyway, so wiring them is new plumbing for zero present benefit. The principle (reuse an existing source of truth over a hardcoded literal) is right; it just has no payoff here because the literals are either binary-coupled or static-until-the-switch. (The real home for product-identity reuse is still the library API — see the box above.)
-
+ > **Could we reuse `library.json`'s `name` now where a literal sits (subtraction, not a new constant)?** Surveyed `moondeck/` for it — verdict: **no genuine low-hanging fruit.** ~95% of `projectMM` literals there are the **binary name** (`build/…/projectMM`, `.bin`, `.exe`, `.log`, `pkill projectMM`, crash `.ips`) which must track the **CMake target**, not `library.json` (wiring them to the product name would break the path to the file on disk); plus one **wire literal** (`_net_probe.py` ArtNet source-name, must byte-match the device) and ~15 prose/docstrings. The only product-name candidates — `generate_manifest.py`'s manifest `name`/`home_assistant_domain` — must *stay* `projectMM` today (Step 2), don't currently read `library.json`, and flip alongside `library.json` in the sweep anyway, so wiring them is new plumbing for zero present benefit. The principle (reuse an existing source of truth over a hardcoded literal) is right; it just has no payoff here because the literals are either binary-coupled or static-until-the-switch. (The real home for product-identity reuse is still the library API — see the box above.)
+ >
> **The constant has a real future home: projectMM/MoonLight as a library.** When the project is offered as an embeddable library, a consumer will want one runtime identity to read (an "About"/banner string, the protocol source-name they can query) — *that* is the ongoing, widely-referenced use a `kProjectName` constant genuinely earns (the test the rename failed). But build it **then**, against a real library API surface (it may want to be a small `ProjectInfo` — name + version + url — not a bare string), per *Concrete first, abstract later* — not speculatively now. Tracked as a seed in [backlog-core](backlog-core.md); when the library work starts, introduce the identity constant as part of its public API and let the wire-strings + UI derive from it.
-4. **Author the mechanical sweep script** — ✅ **Done:** [`scripts/rename/rename_to_moonlight.py`](../../scripts/rename/rename_to_moonlight.py), dry-run by default (`--apply` writes; reserved for switch-day Phase 3.3, *after* the repo rename). What the dry-run against today's tree established: replaces two tokens (`ProjectMM` the enum, then `projectMM`) — a plain token swap is correct for *every* form (repo URL, host path, `projectMM.bin`, product name, `deviceName` slug) since `projectMM` is never a substring of another token; file list comes from `git ls-files` so build output (`build/`, `esp32/build/`) is excluded without a brittle blocklist; `docs/history` (era record) + the rename doc itself are content-excluded. Verified: **542 hits across 113 files**, and `MoonLive` / predecessor `MoonLight` / `namespace mm` are provably never touched (0 files where their count changes). The enum rename is safe — device classification keys on the `"modules"` marker, not the label string. The script de-risks switch-day; it is NOT run with `--apply` until then.
-5. **Prep MoonDeck / `moondeck.json` / bench registry** — ✅ **investigated; nothing to change now, two things flagged for switch-day.** (a) **The functional chain stays `projectMM` until the switch (and flips together in the sweep):** `moondeck_config.json`'s `process_name: "projectMM"` ↔ the CMake binary `projectMM` ↔ the `build//projectMM` run/log path ↔ `pkill projectMM`. These are tracked files the sweep rewrites in one pass, so they stay consistent — changing `process_name` early would break MoonDeck's process detection against today's binary, so don't. (b) **The sweep cannot reach the gitignored bench registry** `scripts/moondeck.json` (it's private, per [[bench-setup]]; the sweep uses `git ls-files`). Its `"board": "projectMM testbench …"` values reference catalog `name`s that *do* flip — so after the switch they'd mismatch only on your bench. **Switch-day local-tooling note: hand-update `scripts/moondeck.json` board names** (and re-provision bench devices if you want the new mDNS identity) — the sweep covers tracked files only. The MoonDeck prose (`MoonDeck.md`, code comments) flips in the normal sweep.
+4. **Author the mechanical sweep script** — ✅ **Done:** [`moondeck/rename/rename_to_moonlight.py`](../../moondeck/rename/rename_to_moonlight.py), dry-run by default (`--apply` writes; reserved for switch-day Phase 3.3, *after* the repo rename). What the dry-run against today's tree established: replaces two tokens (`ProjectMM` the enum, then `projectMM`) — a plain token swap is correct for *every* form (repo URL, host path, `projectMM.bin`, product name, `deviceName` slug) since `projectMM` is never a substring of another token; file list comes from `git ls-files` so build output (`build/`, `esp32/build/`) is excluded without a brittle blocklist; `docs/history` (era record) + the rename doc itself are content-excluded. Verified: **542 hits across 113 files**, and `MoonLive` / predecessor `MoonLight` / `namespace mm` are provably never touched (0 files where their count changes). The enum rename is safe — device classification keys on the `"modules"` marker, not the label string. The script de-risks switch-day; it is NOT run with `--apply` until then.
+5. **Prep MoonDeck / `moondeck.json` / bench registry** — ✅ **investigated; nothing to change now, two things flagged for switch-day.** (a) **The functional chain stays `projectMM` until the switch (and flips together in the sweep):** `moondeck_config.json`'s `process_name: "projectMM"` ↔ the CMake binary `projectMM` ↔ the `build//projectMM` run/log path ↔ `pkill projectMM`. These are tracked files the sweep rewrites in one pass, so they stay consistent — changing `process_name` early would break MoonDeck's process detection against today's binary, so don't. (b) **The sweep cannot reach the gitignored bench registry** `moondeck/moondeck.json` (it's private, per [[bench-setup]]; the sweep uses `git ls-files`). Its `"board": "projectMM testbench …"` values reference catalog `name`s that *do* flip — so after the switch they'd mismatch only on your bench. **Switch-day local-tooling note: hand-update `moondeck/moondeck.json` board names** (and re-provision bench devices if you want the new mDNS identity) — the sweep covers tracked files only. The MoonDeck prose (`MoonDeck.md`, code comments) flips in the normal sweep.
## Phase 2 — the COMING TIME (staged, still pre-switch)
diff --git a/docs/building.md b/docs/building.md
index c7b2c373..9427f494 100644
--- a/docs/building.md
+++ b/docs/building.md
@@ -4,12 +4,12 @@ How to get the system running on a desktop, an ESP32, a Teensy, or a Raspberry P
## MoonDeck — the dev console
-Everything that builds, flashes, runs, tests, monitors, or checks the project — for every target — lives as a script under `scripts/`. The full per-script reference is [scripts/MoonDeck.md](../scripts/MoonDeck.md).
+Everything that builds, flashes, runs, tests, monitors, or checks the project — for every target — lives as a script under `moondeck/`. The full per-script reference is [moondeck/MoonDeck.md](../moondeck/MoonDeck.md).
The scripts have two front ends with the same code and arguments:
-- **CLI** — `uv run scripts//.py`. What agents use; what CI uses. Composes with shell, captures exit codes, parses output.
-- **MoonDeck** — `uv run scripts/moondeck.py`, then open `http://localhost:8420`. A browser dev console wrapping the same scripts: status dots, run/stop toggles for long-running processes, grouped tabs, output panes. The human control deck.
+- **CLI** — `uv run moondeck//.py`. What agents use; what CI uses. Composes with shell, captures exit codes, parses output.
+- **MoonDeck** — `uv run moondeck/moondeck.py`, then open `http://localhost:8420`. A browser dev console wrapping the same scripts: status dots, run/stop toggles for long-running processes, grouped tabs, output panes. The human control deck.
Use whichever fits. Neither path is "more official" than the other; the scripts are the source of truth and the front ends are interfaces. New work adds a script first; both interfaces follow.
@@ -19,7 +19,7 @@ MoonDeck has three tabs:
- **ESP32** — chip type and USB port selection. Build, flash, monitor.
- **Live** — device discovery and monitoring against running devices on the network.
-Script definitions and configuration live in `scripts/moondeck_config.json` (committed). Script documentation lives in `scripts/MoonDeck.md`, one section per script. Runtime state (selected devices, ports) persists in `scripts/moondeck.json` (gitignored).
+Script definitions and configuration live in `moondeck/moondeck_config.json` (committed). Script documentation lives in `moondeck/MoonDeck.md`, one section per script. Runtime state (selected devices, ports) persists in `moondeck/moondeck.json` (gitignored).
## Tooling overview
@@ -52,9 +52,9 @@ The project is structured as a small set of CMake libraries: a core library (pla
Desktop and RPi both build with the root `CMakeLists.txt`. RPi can cross-compile against the same tree or build natively on the device — same source.
```sh
-uv run scripts/build/build_desktop.py # build
-uv run scripts/run/run_desktop.py # run as detached background process
-uv run scripts/test/test_desktop.py # unit tests
+uv run moondeck/build/build_desktop.py # build
+uv run moondeck/run/run_desktop.py # run as detached background process
+uv run moondeck/test/test_desktop.py # unit tests
```
Or use MoonDeck's PC tab for the same operations with a status dot per card. The desktop run detaches and outlives the launching script — the same model as flashing an ESP32, where the device runs independently afterwards.
@@ -103,13 +103,13 @@ git config --global core.longpaths true
git clone --depth 1 --branch release/v6.1 https://github.com/espressif/esp-idf.git "$env:USERPROFILE\esp\esp-idf"
```
-Then run the one-time Python environment setup — either open MoonDeck (`uv run scripts/moondeck.py`), go to the ESP32 tab, and click **Setup ESP-IDF**, or run it directly:
+Then run the one-time Python environment setup — either open MoonDeck (`uv run moondeck/moondeck.py`), go to the ESP32 tab, and click **Setup ESP-IDF**, or run it directly:
```sh
-uv run scripts/build/setup_esp_idf.py # one-time
-uv run scripts/build/build_esp32.py --firmware esp32 # WiFi-only
-uv run scripts/build/flash_esp32.py --firmware esp32 --port /dev/tty.usbserial-XXXX
-uv run scripts/run/monitor_esp32.py --port /dev/tty.usbserial-XXXX
+uv run moondeck/build/setup_esp_idf.py # one-time
+uv run moondeck/build/build_esp32.py --firmware esp32 # WiFi-only
+uv run moondeck/build/flash_esp32.py --firmware esp32 --port /dev/tty.usbserial-XXXX
+uv run moondeck/run/monitor_esp32.py --port /dev/tty.usbserial-XXXX
```
`setup_esp_idf.py` runs the upstream installer for the host: `install.sh` on macOS/Linux, `install.bat` on Windows. Both create the same `~/.espressif/python_env/...` venv and download the same toolchains (~1.5 GB more) — only the wrapper differs. The Windows installer needs roughly 5 minutes on a fast link. It also offers to move a drifted checkout onto the pinned commit (see [ESP-IDF version](#esp-idf-version)); pass `--no-checkout` to keep it warn-only.
@@ -170,7 +170,7 @@ Each row below states where we are and the trigger to move.
| **PSA Crypto** — legacy mbedTLS crypto APIs deprecated in favour of the PSA API | No direct exposure: we never call mbedTLS ourselves; OTA uses `esp_https_ota` + `esp_crt_bundle_attach` ([platform_esp32_ota.cpp](../src/platform/esp32/platform_esp32_ota.cpp)), which wrap crypto internally. | Nothing to migrate while we stay on high-level components. **Watch** only: if a future feature needs hashing/signing directly (e.g. signed-OTA verification, a device identity), write it against the **PSA API** from the start, not legacy mbedTLS. Trigger: first direct crypto use. |
| **`network_provisioning`** — Espressif's Unified Provisioning subsystem, renamed from `wifi_provisioning` in v6.0. Transports: **BLE (GATT)** + **Wi-Fi SoftAP**. Clients: official iOS/Android apps for both, plus `esp_prov` (a Python CLI on Linux/macOS/Windows). Transport-agnostic but ships no web/serial client. | We provision over [Improv](../src/core/ImprovProvisioningModule.h) — serial (USB) + BLE, driven from the **browser** (ESP Web Tools) or a serial CLI. That covers the *web-installer / no-app* onboarding well. What we **don't** have is the IDF-native **phone-app + SoftAP** flow (open the ESP app, pick the device's AP, hand it credentials) that most shipping ESP32 products offer. So this is a real coverage gap, not a duplicate: the two standards meet only on BLE and own different front-ends. | **Adopt to close the gap — this is a planned capability, not a maybe.** `network_provisioning` is in v6.0 (clears the floor) and is *the* IDF-native standard, so embracing it is exactly the stance above. Add it as a **sibling provisioning module** beside ImprovProvisioning (both live as Peripheral/System modules; the device can offer whichever transports its chip supports), reusing the same WiFi-credential plumbing. Not a replacement for Improv — they cover different front-ends (browser vs phone-app), and a product can want either. The phone-app + SoftAP path is the part that makes ESP32 deployment feel product-grade. Trigger: scheduled as one of the v6.0-adoption iterations (see below). |
| **CMake Build System v2** — the named successor to the current build system; technical preview in v6.0/6.1, has its own migration guide | Standard `idf.py` build (v1). Our component is a thin `idf_component_register()` wrapper, so the migration surface is small. | **Watch until it's GA** (not while it's preview — adopting a preview build system would be the opposite of "common patterns first"). Trigger: v2 ships as the default. Then dry-run a build under v2, fix any `idf_component_register()` / Kconfig-dependency fallout, switch. Low risk given how little custom CMake we have. |
-| **Built-in MCP server** (`idf.py mcp-server`) — lets an AI assistant drive build/flash/monitor/debug directly | Not used. Agents and humans both go through the `scripts//*.py` layer (the uniform interface in [scripts/MoonDeck.md](../scripts/MoonDeck.md)), which wraps pin-drift checks, per-firmware build dirs, and KPI collection. | **Evaluate, don't default to it.** The risk is a *second control path* that bypasses our script policy, and it's ESP32-only (no desktop), so it can't be the uniform path. If adopted, wrap it *behind* a script (`scripts/run/idf_mcp.py`) so the policy layer still applies, rather than pointing the agent at raw `idf.py`. Trigger: a concrete debug workflow the scripts can't cover. |
+| **Built-in MCP server** (`idf.py mcp-server`) — lets an AI assistant drive build/flash/monitor/debug directly | Not used. Agents and humans both go through the `moondeck//*.py` layer (the uniform interface in [moondeck/MoonDeck.md](../moondeck/MoonDeck.md)), which wraps pin-drift checks, per-firmware build dirs, and KPI collection. | **Evaluate, don't default to it.** The risk is a *second control path* that bypasses our script policy, and it's ESP32-only (no desktop), so it can't be the uniform path. If adopted, wrap it *behind* a script (`moondeck/run/idf_mcp.py`) so the policy layer still applies, rather than pointing the agent at raw `idf.py`. Trigger: a concrete debug workflow the scripts can't cover. |
The general rule: **anything already in v6.0 we adopt proactively** (it clears the floor, so there's no reason to wait — EIM and `network_provisioning` are both here), while **preview / not-yet-in-v6.0 features wait** until they're stable *and* in our floor. Each adoption is its own commit with its own hardware re-test, and none may regress the Teensy / desktop paths.
@@ -186,11 +186,11 @@ 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 [`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.
+The canonical list is the **`FIRMWARES` dict** in [`moondeck/build/build_esp32.py`](../moondeck/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).
-Each firmware has its own build dir at `build/esp32-/`, so all variants can coexist on disk. `build_esp32.py` points `idf.py -B` at the per-firmware dir; switching firmwares is just a different `--firmware` argument, no clean rebuild penalty. Same-firmware rebuilds stay incremental, as before. Disk usage scales with the number of firmwares built (≈100 MB each), and a future rename would orphan the old dir — clean with `scripts/build/clean_esp32.py --firmware ` or `--all`.
+Each firmware has its own build dir at `build/esp32-/`, so all variants can coexist on disk. `build_esp32.py` points `idf.py -B` at the per-firmware dir; switching firmwares is just a different `--firmware` argument, no clean rebuild penalty. Same-firmware rebuilds stay incremental, as before. Disk usage scales with the number of firmwares built (≈100 MB each), and a future rename would orphan the old dir — clean with `moondeck/build/clean_esp32.py --firmware ` or `--all`.
If a firmware *key* changes its feature set (e.g. the classic `esp32` collapse turned a WiFi-only key into WiFi+Ethernet), its existing build dir would otherwise keep the old `MM_NO_ETH` / `MM_ETH_ONLY` in `CMakeCache.txt` — CMake `-D` flags are written to the cache, and omitting one on a later configure does *not* clear it, so the stale value would silently build the old feature set (Ethernet stubbed out, no link, no LED, and a flash erase wouldn't help because it's a compile-time define). `build_esp32.py` guards this: before reusing a dir it compares the cached feature flags to what the firmware wants and wipes the dir for a clean reconfigure on a mismatch (printing the reason). Same-feature rebuilds are untouched, so the incremental fast path is preserved.
@@ -198,7 +198,7 @@ Each ESP32-S3 SKU has its own firmware key because the sdkconfig fragment encode
The Ethernet PHY type and pin map are runtime config, not baked into the build: each firmware carries the driver(s) its chip can host (RMII EMAC for classic/P4, W5500 SPI for S3), and `deviceModels.json` supplies the per-board PHY/pins (pushed into NetworkModule's eth controls at provision). The classic chip default is the common LAN8720 RMII wiring (reset GPIO 5, MDIO addr 0, clock GPIO 17 — e.g. the Olimex ESP32-Gateway), so a board with the same PHY but a different pinout (e.g. WT32-ETH01 with reset on GPIO 16) just needs a different `deviceModels.json` entry — no rebuild.
-`--profile` is accepted one release for migration: `--profile default` → `--firmware esp32`, `--profile eth-only` → `--firmware esp32-eth`. The legacy `build_esp32_ethonly.py` wrapper still works (it now forwards `--firmware esp32-eth`).
+`--profile` is accepted one release for migration: `--profile default` → `--firmware esp32`, `--profile eth-only` → `--firmware esp32-eth`.
### Why not Arduino
diff --git a/docs/coding-standards.md b/docs/coding-standards.md
index 871144d6..cb50dde6 100644
--- a/docs/coding-standards.md
+++ b/docs/coding-standards.md
@@ -95,7 +95,7 @@ All targets build warnings-as-errors: `-Wall -Wextra -Werror` on Clang/GCC (macO
## Static checks
-- **Platform boundary** (`scripts/check/check_platform_boundary.py`) — scans all files outside `src/platform/` for `#ifdef` / `#if defined` with platform macros and `#include` of platform-specific headers (`esp_*`, `freertos/*`, `driver/*`, `SDL.h`, `wiringPi.h`, …). Fails if any are found. The platform boundary rule itself: [architecture.md § Platform abstraction](architecture.md#platform-abstraction).
+- **Platform boundary** (`moondeck/check/check_platform_boundary.py`) — scans all files outside `src/platform/` for `#ifdef` / `#if defined` with platform macros and `#include` of platform-specific headers (`esp_*`, `freertos/*`, `driver/*`, `SDL.h`, `wiringPi.h`, …). Fails if any are found. The platform boundary rule itself: [architecture.md § Platform abstraction](architecture.md#platform-abstraction).
- **Hot path lint** — flags allocation calls (`new`, `malloc`, `make_unique`, `make_shared`, `push_back`, `std::string` constructors) inside functions identified as hot path (render loop and callees). A code-review convention, enforced by hand. The hot path rule itself: [architecture.md § Hot path discipline](architecture.md#hot-path-discipline).
- **Code formatting** — `clang-format` with a project `.clang-format` file. Applied in CI; code that doesn't match fails the check. Run locally via editor integration or `clang-format -i`.
@@ -116,13 +116,13 @@ Where each kind of fact lives. The guiding rule: **document a thing once, in the
### The two surfaces
-1. **Summary page (hand-written, end-user).** One `.md` per module *group*, a 4-column table — **name + description · gif/image · controls · links** (per module: tests · its technical page · attribution · anchors to any extra prose). One row per module, authored as `### ` prose blocks that a build-time hook ([`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:
+1. **Summary page (hand-written, end-user).** One `.md` per module *group*, a 4-column table — **name + description · gif/image · controls · links** (per module: tests · its technical page · attribution · anchors to any extra prose). One row per module, authored as `###` prose blocks that a build-time hook ([`moondeck/docs/mkdocs_hooks.py`](../moondeck/docs/mkdocs_hooks.py)) renders as the table. Catalog controls live here because a catalog module's user surface is its runtime `controls_.add(...)` calls, which no static tool sees. Each group is nested in its own subfolder, consistent with the catalog:
- `light/{effects,modifiers,layouts,drivers}/.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/.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.
+2. **Technical page (generated).** `docs/moonmodules/{core,light}/moxygen/.md`, produced from the `.h` by [`moondeck/docs/gen_api.py`](../moondeck/docs/gen_api.py): **Doxygen** (the de-facto-standard C++ parser) emits XML, **moxygen** renders Markdown through a custom Handlebars template ([`moondeck/docs/moxygen-templates/`](../moondeck/docs/moxygen-templates/)). Layout is controlled only through Doxygen config + that template — moxygen's own levers, the output is used verbatim, never post-processed. Each page carries the module's **class, variables, and members** from their `///` comments and links to its `.h`; a summary page's per-module link points here.
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.
@@ -134,4 +134,4 @@ A module's story therefore lives in exactly two places: its `.h` (technical, gen
### 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.
+`docs/tests/*.md` is generated by [`moondeck/docs/generate_test_docs.py`](../moondeck/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 4b908f6b..d6d8499d 100644
--- a/docs/gettingstarted.md
+++ b/docs/gettingstarted.md
@@ -257,7 +257,7 @@ keep going.
- **Run it on your computer** instead of (or alongside) an ESP32 — macOS, Windows,
Linux: [project overview → Getting started](../README.md#getting-started).
- **Manage several devices, build, and flash from one console** with MoonDeck, our
- developer tool: [MoonDeck guide](../scripts/MoonDeck.md).
+ developer tool: [MoonDeck guide](../moondeck/MoonDeck.md).
- **Build from source** or target Teensy / Raspberry Pi: [building.md](building.md).
Stuck, or something didn't work? Open an
diff --git a/docs/history/README.md b/docs/history/README.md
index 7789ef64..8700da2b 100644
--- a/docs/history/README.md
+++ b/docs/history/README.md
@@ -1,6 +1,6 @@
# History — index
-The backward-looking half of the docs (the forward-looking half is [`../backlog/`](../backlog/)). This folder is **not** present-tense and agents don't read it automatically — only when planning new work. See [CLAUDE.md § Documentation](../../CLAUDE.md) for how `history/` and `backlog/` relate.
+The backward-looking half of the docs (the forward-looking half is [`../backlog/`](../backlog/README.md)). This folder is **not** present-tense and agents don't read it automatically — only when planning new work. See [CLAUDE.md § Documentation](../../CLAUDE.md) for how `history/` and `backlog/` relate.
*Living index — kept current as the friend-repo digests are updated each month; the git log carries exact dates.*
diff --git a/docs/history/decisions.md b/docs/history/decisions.md
index 3a46b6e7..359d84af 100644
--- a/docs/history/decisions.md
+++ b/docs/history/decisions.md
@@ -449,7 +449,7 @@ The plan-18 branch landed plans 17 + 18 + six unplanned follow-ups (19, 19.1, 20
- **Per-`ControlType` behaviour belongs with the type, not with the caller.** Three call sites (`HttpServerModule::writeControls`, `FilesystemModule::writeValue`, `scenario_runner.cpp::writeJsonValAsValue` and twin apply paths) were each carrying their own `switch (c.type)` over the same enum. Each was 50-60 lines, hand-maintained in parallel, and silently drifted (the scenario runner had stopped recognising new types added for the FS path). The fix was to extract the switch into a small set of free functions in `src/core/Control.cpp` (`writeControlValue`, `writeControlMetadata`, `applyControlValue`, `isPersistable`, `hasDefault`, `controlTypeName`) and route the three call sites through them. Two cross-cutting requirements made this work: (1) `JsonSink` gained a third "fixed-buffer" mode (alongside socket and heap-grow) so the FS path could share the serializer without growing a per-value allocation, and (2) the apply path got an `ApplyPolicy` parameter (`Strict` for HTTP, `Clamp` for FS load) so the tolerant-load semantics survived the consolidation. Codified in `docs/coding-standards.md § Per-type behaviour lives with the type` — applies whenever the same `switch` appears in 2+ places. Counter-example also in that section: if only one caller needs the behaviour, keep it at the call site (a one-shot switch is cheaper than a function with one user).
-- **Local Improv testing closes the dev-loop gap that made every Improv change a high-friction commit.** Before this branch, the only way to verify the Improv flow end-to-end was to tag a release, wait for CI, deploy to GitHub Pages, then flash from the live web installer — each iteration of the docs/install page or release-picker.js took minutes and burned a release tag. `scripts/run/preview_installer.py` now has a "flash-ready" mode that stages every local `build/esp32-*/projectMM.bin` under `releases/latest/` in the preview server's tree, generates matching Pages-relative manifests via the same `generate_manifest.py` production uses, and serves the install page at `localhost:8000`. The picker resolves the GitHub `latest` tag to the staged local bins via `toLocalUrl`, and Web Serial works on `localhost` without the secure-origin requirement that gates the public site — so an end-to-end flash + Improv WiFi provision is verifiable without touching any release. Paired with `scripts/build/improv_smoke_test.py` (probe + WiFi provision + LAN reachability, all three steps as a single CLI), which gives a deterministic pass/fail for the device-side Improv state machine and is wired into MoonDeck. Lesson: when a feature can only be tested in production, the iteration cost compounds into "don't touch it" — building the local test loop, even if it takes a day, pays for itself within a few commits.
+- **Local Improv testing closes the dev-loop gap that made every Improv change a high-friction commit.** Before this branch, the only way to verify the Improv flow end-to-end was to tag a release, wait for CI, deploy to GitHub Pages, then flash from the live web installer — each iteration of the docs/install page or release-picker.js took minutes and burned a release tag. `moondeck/run/preview_installer.py` now has a "flash-ready" mode that stages every local `build/esp32-*/projectMM.bin` under `releases/latest/` in the preview server's tree, generates matching Pages-relative manifests via the same `generate_manifest.py` production uses, and serves the install page at `localhost:8000`. The picker resolves the GitHub `latest` tag to the staged local bins via `toLocalUrl`, and Web Serial works on `localhost` without the secure-origin requirement that gates the public site — so an end-to-end flash + Improv WiFi provision is verifiable without touching any release. Paired with `moondeck/build/improv_smoke_test.py` (probe + WiFi provision + LAN reachability, all three steps as a single CLI), which gives a deterministic pass/fail for the device-side Improv state machine and is wired into MoonDeck. Lesson: when a feature can only be tested in production, the iteration cost compounds into "don't touch it" — building the local test loop, even if it takes a day, pays for itself within a few commits.
## Lessons from this branch (Board injection follow-ups)
@@ -713,7 +713,7 @@ The installer was reworked so a board catalog (`boards.json`) sets a device up f
**A periodic re-broadcast to let late joiners "catch up" is a hack wearing a keepalive costume.** The 3D preview sends a coordinate table (positions) once, then per-frame colour. The original implementation re-sent the *whole table every ~1 second* "so a client that connected after the last rebuild catches it." It looked fine — a fresh page recovered within a second — so it shipped and sat there. But it's a workaround, not the mechanism: it rebuilt the full table from the layout **every tick-second forever**, on the hot path, whether or not anyone had connected and whether or not anything changed — and it papered over a missing request/response with polling. The correct construct is event-driven: send the table **when it actually changes** (`onBuildState` — grid/layout/LUT rebuild) and **when a client asks** (a new WS connection bumps `BinaryBroadcaster::clientGeneration()`, which `PreviewDriver::loop()` watches and re-sends on change). That's strictly *less* code than the timer and zero idle cost. How it sneaked past review: the workaround *worked* in casual testing and its cost was invisible until a later change made each rebuild heavier and the per-module tick was profiled. Lessons: (1) "re-send periodically so it eventually syncs" is the polling-instead-of-events smell — ask "what's the event that should trigger this?" and trigger on *that*; (2) a recurring rebuild on the hot path must justify itself every tick, so "every second, just in case" fails *Data over objects / fastest hot path* on sight; (3) this is *Continuous refactor, no hacks* — the fix isn't a scheduled cleanup, it's "the moment you see a keepalive timer standing in for a request, replace it." The guard is a test that advances the clock several seconds with no client change and asserts the table is **not** re-sent (the old timer would have).
-**When a working seam regresses after your "fix," suspect the fix — and measure with a tool faithful to what the user sees.** The resumable preview send (`sendBufferedFrame`/`drainPreviewSend` — stream the producer buffer a memory-adaptive chunk per `loop20ms`, drop-new backpressure, downsample + display cap) shipped working on all three boards. A later attempt to *also* route the coordinate table + downsampled colour frame through the resumable path (removing the synchronous `sendAllOrClose` spin-and-close) looked principled but **regressed every board into an intermittent stall**, through several variants. Three lessons compounded: (1) **Stop at the first failed fix on a working path.** Each "improvement" to a seam that already worked added a new failure; the discipline (CLAUDE.md *Anti-stalling*) is to revert to the known-good state at attempt two, not re-engineer. The committed synchronous coord/downsampled path *closes a wedged client past a spin budget and lets it reconnect* — not elegant, but proven; the elegant unification wasn't worth a regression nobody could pin. (2) **A measurement tool must be faithful to the real client or it invents and hides bugs.** A plain one-shot WebSocket probe *gave up on close* where a browser *reconnects*, so it reported stalls users never saw AND missed blips they did — it sent the debugging in circles for a whole session. The fix was a browser-faithful probe (`scripts/diag/preview_health.py`: reads binary frames, sends the 25 s keepalive ping, auto-reconnects with backoff — exactly `app.js`'s `connectWs`); only then did probe and eyes agree. (3) **A stale process masquerades as a code bug.** "No preview on the PC build" with a corrupt-looking on-the-wire coordinate count survived every fresh rebuild — because a **38-hour-old desktop binary** still held port 8080; the freshly-built one couldn't bind and the browser/probe hit the stale one. The tell was the *uptime*, spotted by the product owner, not the diff. Lesson: before bisecting a "rebuild didn't fix it" bug, confirm the artifact under test is the one you built (check the process uptime / `build` timestamp / what's actually bound to the port). The faithful probe is now the standing way to measure preview health on any target (PC + the three boards), wired into MoonDeck's Live tab.
+**When a working seam regresses after your "fix," suspect the fix — and measure with a tool faithful to what the user sees.** The resumable preview send (`sendBufferedFrame`/`drainPreviewSend` — stream the producer buffer a memory-adaptive chunk per `loop20ms`, drop-new backpressure, downsample + display cap) shipped working on all three boards. A later attempt to *also* route the coordinate table + downsampled colour frame through the resumable path (removing the synchronous `sendAllOrClose` spin-and-close) looked principled but **regressed every board into an intermittent stall**, through several variants. Three lessons compounded: (1) **Stop at the first failed fix on a working path.** Each "improvement" to a seam that already worked added a new failure; the discipline (CLAUDE.md *Anti-stalling*) is to revert to the known-good state at attempt two, not re-engineer. The committed synchronous coord/downsampled path *closes a wedged client past a spin budget and lets it reconnect* — not elegant, but proven; the elegant unification wasn't worth a regression nobody could pin. (2) **A measurement tool must be faithful to the real client or it invents and hides bugs.** A plain one-shot WebSocket probe *gave up on close* where a browser *reconnects*, so it reported stalls users never saw AND missed blips they did — it sent the debugging in circles for a whole session. The fix was a browser-faithful probe (`moondeck/diag/preview_health.py`: reads binary frames, sends the 25 s keepalive ping, auto-reconnects with backoff — exactly `app.js`'s `connectWs`); only then did probe and eyes agree. (3) **A stale process masquerades as a code bug.** "No preview on the PC build" with a corrupt-looking on-the-wire coordinate count survived every fresh rebuild — because a **38-hour-old desktop binary** still held port 8080; the freshly-built one couldn't bind and the browser/probe hit the stale one. The tell was the *uptime*, spotted by the product owner, not the diff. Lesson: before bisecting a "rebuild didn't fix it" bug, confirm the artifact under test is the one you built (check the process uptime / `build` timestamp / what's actually bound to the port). The faithful probe is now the standing way to measure preview health on any target (PC + the three boards), wired into MoonDeck's Live tab.
**Don't hold a vendor library's async handle across your own event loop — it races the library's internal timers.** A UI refresh intermittently crashed the device (`assert failed: xQueueSemaphoreTake queue.c:1709 (( pxQueue ))` — a null FreeRTOS queue — inside the espressif mDNS component's `mdns_query_async_get_results`, plus an `Interrupt wdt timeout`). The mDNS *browse* (discovering peers for the "Your devices" list — distinct from mDNS *advertise*, which serves `.local` and was never the problem) used the async API: `mdns_query_async_new` returns a handle that `DevicesModule` held across ticks, polling it each `loop1s` with a 0 ms timeout. The trap: the mDNS component's **own task** owns that handle's queue and **frees it when the query's window (3 s) expires** — so a poll landing in the gap after expiry asserts on a freed queue. It was intermittent and grid-size-sensitive (a bigger grid lengthens the tick, widening the gap) and looked like "refresh crashes it" only because a refresh's activity coincided with the poll. **First fix attempt was wrong:** I assumed a *service-table mutation* (live rename re-registering `_http._tcp`) tore the handle down and added a cancel-before-mutate guard — it didn't fix it, because the freeing party is the component's expiry timer, not our code. **Real fix:** stop using the async-handle API entirely — replace the start/poll/stop trio with one synchronous `mdnsBrowse()` (`mdns_query_ptr`) that queries, delivers results, and frees everything in a single call, holding **no handle across ticks**, so the race window can't exist. The catch that synchronous introduced: `mdns_query_ptr` blocks the *full* timeout (it waits the whole window for late responders, no early return) and `loop1s` is charged to the tick — an 80 ms query tanked the tick. So **throttle**: browse one service type every ~8th tick with a ~60 ms timeout — one brief hiccup every ~8 s, invisible for discovery, FPS untouched in between. Lessons: (1) a library's async/iterator handle is only valid between *its* lifecycle events — if you can't see/where those fire (here, an internal expiry timer on another task), don't hold the handle across your loop; prefer a self-contained synchronous call that owns the whole lifecycle. (2) An *intermittent, load-dependent* crash whose backtrace sits in a vendor component is a **lifecycle race**, not a component bug — but find the *actual* concurrent actor before "fixing" (my first guess at the actor was wrong and the fix did nothing). (3) Trading async for synchronous trades a race for a blocking cost — budget it (throttle + bound the timeout) so the cure isn't a tick-killer. (4) Desktop stubs these mDNS calls to no-ops, so it's a hardware-only fix the unit suite can't reach; the reproduction (concurrent WS churn at a large grid → crash before, stable after, uptime climbing) is the proof, in the commit, not a desktop test.
@@ -837,3 +837,23 @@ The move: **lift `setControl` onto the `Scheduler`** (which owns the module tree
## An RMT/IR done-callback runs in ISR context: signal only, decode on the task
The first NEC-decoder version crash-looped the board (USB dropped, port re-enumerated) because the RMT `on_recv_done` callback both **decoded** the frame (calling non-`IRAM_ATTR` helpers) and **re-armed** `rmt_receive()` — from interrupt context. On ESP32, an ISR that jumps to flash-resident code while the cache is disabled, or re-enters a driver call, faults. The existing working RX capture (`rmtWs2812RxCapture`) already showed the safe shape and I didn't follow it at first: **the ISR only records the symbol count and signals a queue; the decode + re-arm happen on the task** (in `irRead`, called from the render loop). Rule: an RMT/GPIO done-callback is ISR context — it may touch only its stack, the passed event data, and an ISR-safe FreeRTOS primitive (`xQueueSendFromISR`); everything else (parsing, logging, re-arming the peripheral) moves to the waking task. When in doubt, copy the codebase's already-proven callback, don't re-derive it.
+
+## An MQTT (or any integration) topic identity must be a stable hardware id, never the editable name
+
+The MQTT module first derived its topic prefix from the user-editable `deviceName` (`projectMM/`) — attractive because it reads well and a rename "follows" on the device. On the bench a rename (ShellyOne → ShellyTwo) instantly repointed every topic and orphaned the Homebridge config: the hub kept publishing to the old topics, the device now listened on new ones, and the light went "not responding." Researching WLED (which projectMM already emulates), Tasmota, ESPHome, and Home-Assistant MQTT discovery found the finding unanimous: **the machine-facing identity is anchored to a stable hardware id (MAC / chip-id), and the human display name is a separate field.** WLED's default device topic is `wled/`, rename-stable; the friendly `serverDescription` is decoupled. HA discovery *requires* a stable `unique_id` precisely so a friendly-name change doesn't break automations, and explicitly forbids device name / hostname as identity (they change).
+
+The fix: topics derive from `projectMM/` (stable for the chip's life), and the friendly `deviceName` rides a separate retained `/name` topic. A rename now updates only the display label; control never breaks. General rule: **when a config value is a control-plane identity that external systems bind to (a topic, a discovery id, an API key path), derive it from something immutable and keep the human-readable name a separate, published-but-non-identifying field.** The convenience of "the name is the identity" is a trap — it couples a display concern to a wire contract.
+
+## Don't promote one specific board's pins to "the chip default"
+
+`NetworkModule::ethType` defaulted to a per-chip `ethConfigDefault`, and the classic-ESP32 / P4 / S31 entries in that table were, on inspection, **one specific board's wiring each** (the classic default was "e.g. Olimex," the P4 default was the Waveshare NANO's pins, the S31 default was the Function-CoreBoard's) dressed up as a chip property. Two failures fell out: a WiFi-only board (the LOLIN-D32 Shelly) wasted an Ethernet-PHY init on hardware it lacks, and — worse — the QuinLED Dig-Octa, which sets custom MDC/MDIO but has *no software eth reset* (GPIO5 is an LED output on that board, per its published pinout), would have inherited the classic default reset=GPIO5 and driven an **LED pin as an Ethernet reset**. The reviewer's instinct ("are these industry standards, or a device we happened to use?") was exactly right.
+
+The fix: `ethType` defaults to **None** — Ethernet is opt-in, and every board that has it declares its own `ethType` + board-wiring pins explicitly in `deviceModels.json`; a validator (`check_devices.py`) enforces "ethType set → board-wiring pins present." The pin *defaults* still seed a board that opts in, but nothing relies on them silently. General rule: **a "default" that is really one board's values is bespoke masquerading as standard (a § Principles violation); make the capability opt-in and require each consumer to state its own values, so a missing declaration fails loudly instead of inheriting a stranger's wiring.**
+
+## A new firmware default only reaches a factory-fresh device; persistence wins on an upgrade
+
+After changing the `ethType` firmware default to None and reflashing an already-provisioned board, the device *still* reported the old value — because persistence (`FilesystemModule` overlays every saved control at boot) restores the value captured during the earlier provisioning, and a plain `flash write` keeps the filesystem. The same shape bit the MQTT prefix: an old-firmware device carried a stale stored prefix a reflash didn't clear. This is *correct* persistence behavior (saved config wins over a constructor default), but it means **a default change is invisible to any device that has already run** — only an erase-flash or an explicit re-set surfaces it. General rule: when you change a default to fix a bug, remember the fix lands only on factory-fresh devices; an already-provisioned fleet needs an explicit migration (re-inject, an erase, or a one-shot "clear if stale" on upgrade) — and when debugging "my fix didn't take," check the persisted `.config/*.json` before the firmware default.
+
+## Fixing "blocking in the tick" at the connect misses the same violation at the write
+
+The MQTT client's first version blocked the render loop twice — a synchronous `connect()` (with DNS) *and* an unbounded blocking `write()` retry — both inside `loop1s`, which runs in `Scheduler::tick`. The first review caught the connect and it was reworked to non-blocking `connectStart`/`connectPoll`; the *re-review* caught that `conn_.write()` was still there, one layer up: a zero-window broker (accepts TCP, stops reading) fills the send buffer and the next `write` never returns — the render loop freezes permanently, and the keepalive timeout can't even fire because control never comes back. The fix mirrored the connect one: sends go through a non-blocking `writeSome` with an all-or-reset rule (control packets are ≤256 B, so atomic-send is realistic). General rule: **a "no blocking in the hot path" audit must sweep every syscall the path can reach, not just the obvious one** — connect, DNS, read, *and* write. Fixing the loudest blocker while an equally-blocking sibling survives is a partial fix that reads as complete; the re-review exists to catch exactly that.
diff --git a/docs/history/plans/Plan-20260519 - ESP32 Deployment.md b/docs/history/plans/Plan-20260519 - ESP32 Deployment.md
index deab2d05..eb2214a8 100644
--- a/docs/history/plans/Plan-20260519 - ESP32 Deployment.md
+++ b/docs/history/plans/Plan-20260519 - ESP32 Deployment.md
@@ -122,7 +122,7 @@ Actual `wifi_credentials.h` is gitignored.
1. `cmake --build build` — desktop still builds, zero warnings
2. `cd build && ctest --output-on-failure` — all tests pass
3. `./build/test/mm_scenarios` — scenario passes
-4. `python scripts/check/check_platform_boundary.py` — passes
+4. `python moondeck/check/check_platform_boundary.py` — passes
5. `cd esp32 && idf.py set-target esp32 && idf.py build` — ESP32 builds
6. Flash + monitor: WiFi connects, serial shows "mmv3 running", ArtNet packets arrive at receiver
7. Lights visible on hub75 panel from ESP32
diff --git a/docs/history/plans/Plan-20260520 - Adaptive Memory Allocation & Memory Scenario Testing.md b/docs/history/plans/Plan-20260520 - Adaptive Memory Allocation & Memory Scenario Testing.md
index 9eec5a52..a3fb8b96 100644
--- a/docs/history/plans/Plan-20260520 - Adaptive Memory Allocation & Memory Scenario Testing.md
+++ b/docs/history/plans/Plan-20260520 - Adaptive Memory Allocation & Memory Scenario Testing.md
@@ -118,7 +118,7 @@ Scenarios that verify memory behavior. Both in-process and live.
- Each step: measure heap, check bounds
- On ESP32: observe degradation cascade kicking in at some grid size
-**Live runner** (`scripts/scenario/run_live_scenario.py`):
+**Live runner** (`moondeck/scenario/run_live_scenario.py`):
- Parse heap/maxBlock bounds from scenario JSON
- Report memory deltas per step
@@ -177,7 +177,7 @@ test/scenarios/memory-boot.json
test/scenarios/memory-1to1.json
test/scenarios/memory-shuffled.json
test/scenarios/memory-scaling.json
-scripts/scenario/run_live_scenario.py # memory bounds support
+moondeck/scenario/run_live_scenario.py # memory bounds support
docs/architecture-light.md # memory tiers, invariants
docs/moonmodules/core/MoonModule.md
docs/moonmodules/light/Layer.md
diff --git a/docs/history/plans/Plan-20260520 - Live Scenario Testing (Item 8).md b/docs/history/plans/Plan-20260520 - Live Scenario Testing (Item 8).md
index 01b91497..e9acedac 100644
--- a/docs/history/plans/Plan-20260520 - Live Scenario Testing (Item 8).md
+++ b/docs/history/plans/Plan-20260520 - Live Scenario Testing (Item 8).md
@@ -80,7 +80,7 @@ Actually, for live scenarios we just need the values. The Python runner calls `G
### 5. Python live scenario runner
-`scripts/scenario/run_live_scenario.py`:
+`moondeck/scenario/run_live_scenario.py`:
- Connects to a device via HTTP (host:port)
- Reads scenario JSON (same format as in-process)
- Executes steps:
@@ -113,10 +113,10 @@ src/light/DriverGroup.h # MODIFY: addChild/removeChild, role()
src/light/LayoutGroup.h # MODIFY: addChild/removeChild, role()
src/light/Layer.h # MODIFY: addChild/removeChild, role()
src/main.cpp # MODIFY: register module types with factory
-scripts/scenario/run_live_scenario.py # NEW: Python HTTP scenario runner
-scripts/moondeck_config.json # MODIFY: add Live tab entries
-scripts/moondeck_ui/index.html # MODIFY: Live tab content
-scripts/moondeck_ui/app.js # MODIFY: device discovery UI
+moondeck/scenario/run_live_scenario.py # NEW: Python HTTP scenario runner
+moondeck/moondeck_config.json # MODIFY: add Live tab entries
+moondeck/moondeck_ui/index.html # MODIFY: Live tab content
+moondeck/moondeck_ui/app.js # MODIFY: device discovery UI
test/scenarios/control-change.json # NEW: scenario with set_control steps
docs/moonmodules/core/HttpServerModule.md # MODIFY: new endpoints
docs/testing.md # MODIFY: live scenario section
diff --git a/docs/history/plans/Plan-20260521 - Control-list-driven JSON persistence (item 11).md b/docs/history/plans/Plan-20260521 - Control-list-driven JSON persistence (item 11).md
index 31aefb6d..3c55e4c9 100644
--- a/docs/history/plans/Plan-20260521 - Control-list-driven JSON persistence (item 11).md
+++ b/docs/history/plans/Plan-20260521 - Control-list-driven JSON persistence (item 11).md
@@ -234,11 +234,11 @@ saveSubtree(m):
| 1 | Desktop build | `cmake --build /Users/ewoud/Developer/GitHub/ewowi/projectMM/build` (zero warnings) |
| 2 | Unit tests | `cd build && ctest --output-on-failure` |
| 3 | Scenario tests | `./build/test/mm_scenarios` (SIGABRT exit pre-existing on HEAD — accept) |
-| 4 | Platform boundary | `python3 scripts/check/check_platform_boundary.py` — verify no platform leakage in FilesystemModule.h |
-| 5 | Spec check | `python3 scripts/check/check_specs.py` — confirms FilesystemModule.md describes the implemented API |
-| 6 | ESP32 build | `python3 scripts/build/build_esp32.py` — clean. Verify partition + LittleFS still work. |
+| 4 | Platform boundary | `python3 moondeck/check/check_platform_boundary.py` — verify no platform leakage in FilesystemModule.h |
+| 5 | Spec check | `python3 moondeck/check/check_specs.py` — confirms FilesystemModule.md describes the implemented API |
+| 6 | ESP32 build | `python3 moondeck/build/build_esp32.py` — clean. Verify partition + LittleFS still work. |
| 7 | Reviewer agent | Opus reviewer over staged diff. Flag: no heap alloc in `loop1s()` save path (only stack buffers); platform boundary clean; no duplication of JSON helpers; JsonUtil.h stays at ~50 lines (not growing into a JSON library). |
-| 8 | KPI collection | `python3 scripts/check/collect_kpi.py --commit` |
+| 8 | KPI collection | `python3 moondeck/check/collect_kpi.py --commit` |
| 9 | Live scenarios | Run on ESP32 hardware: existing 7 scenarios pass. Manual: set deviceName via REST → reboot → verify deviceName persisted. |
| 10 | Documentation | spec + architecture + testing updated; item 11 removed from `docs/plan.md`. |
diff --git a/docs/history/plans/Plan-20260522 - Nest child module cards inside their parent card's box.md b/docs/history/plans/Plan-20260522 - Nest child module cards inside their parent card's box.md
index cf58d5b1..f6207e0e 100644
--- a/docs/history/plans/Plan-20260522 - Nest child module cards inside their parent card's box.md
+++ b/docs/history/plans/Plan-20260522 - Nest child module cards inside their parent card's box.md
@@ -26,7 +26,7 @@ Two files changed: `src/ui/app.js` and `src/ui/style.css`. No backend change. `s
## Verification
-- **Build** — `python3 scripts/build/build_desktop.py` from a clean `build/` (the old build cache held stale `projectMM-v3` paths from the directory rename and was removed). Zero warnings; `ui_embed` regenerated `ui_embedded.h` from the edited assets.
+- **Build** — `python3 moondeck/build/build_desktop.py` from a clean `build/` (the old build cache held stale `projectMM-v3` paths from the directory rename and was removed). Zero warnings; `ui_embed` regenerated `ui_embedded.h` from the edited assets.
- **Rendered DOM** — drove headless Chrome via CDP, selected the `Layer` root (`localStorage['mm_selectedRoot']`), asserted on the live DOM: root card is `Layer`; its child order is exactly `["card-title", "card-children", "card-footer"]`; the `.card-children` wrapper holds 2 child cards (`Noise`, `Mirror`) as direct descendants; `+ add child` sits below the children block. All assertions passed.
- **Tests** — `ctest` 1/1 passed, `./build/test/mm_scenarios` 8/8 passed. UI-only change, no C++ touched, so test results unaffected as expected.
diff --git a/docs/history/plans/Plan-20260522 - UI rewrite to ui-spec.md baseline (item 12).md b/docs/history/plans/Plan-20260522 - UI rewrite to ui-spec.md baseline (item 12).md
index 7b1d97fb..bd2283bf 100644
--- a/docs/history/plans/Plan-20260522 - UI rewrite to ui-spec.md baseline (item 12).md
+++ b/docs/history/plans/Plan-20260522 - UI rewrite to ui-spec.md baseline (item 12).md
@@ -230,9 +230,9 @@ Per CLAUDE.md pre-commit checklist (10 steps). Specific to this plan:
1. `cmake --build build` — zero warnings (UI changes don't affect build but engine changes do)
2. `ctest --output-on-failure` — existing tests pass + 2 new (`test_movechild`, `test_module_factory`)
3. `./build/test/mm_scenarios` — exit 0
-4. `python3 scripts/check/check_platform_boundary.py` — PASS (new `platform::reboot` correctly placed)
-5. `python3 scripts/check/check_specs.py` — `10+ modules ok` (HttpServer/SystemModule/MoonModule specs updated)
-6. `python3 scripts/build/build_esp32.py` — clean; `ui_embedded.h` regenerated
+4. `python3 moondeck/check/check_platform_boundary.py` — PASS (new `platform::reboot` correctly placed)
+5. `python3 moondeck/check/check_specs.py` — `10+ modules ok` (HttpServer/SystemModule/MoonModule specs updated)
+6. `python3 moondeck/build/build_esp32.py` — clean; `ui_embedded.h` regenerated
7. Reviewer agent (Opus) over the staged diff
8. KPI one-liner with PC + ESP32 tick/FPS per CLAUDE.md step 8
9. Hardware smoke test at `http://192.168.1.210/`:
diff --git a/docs/history/plans/Plan-20260523 - Top-level shape change to `Layouts`, `Layers`, `Drivers`.md b/docs/history/plans/Plan-20260523 - Top-level shape change to `Layouts`, `Layers`, `Drivers`.md
index afe43f24..dd243bcb 100644
--- a/docs/history/plans/Plan-20260523 - Top-level shape change to `Layouts`, `Layers`, `Drivers`.md
+++ b/docs/history/plans/Plan-20260523 - Top-level shape change to `Layouts`, `Layers`, `Drivers`.md
@@ -178,7 +178,7 @@ The UI is module-driven — it renders whatever the tree says. `acceptsChildren`
Wait — that adds noise. Let me reconsider:
- **(b) is the lightest:** `acceptsChildren` for `"Layers"` is hardcoded to `[Layer]` (by typeName, not role). The UI already special-cases this kind of containment via `acceptsChildren`. The role chip on each `Layer` card stays Generic (⚙️). Slightly cluttered emoji-wise but no role-enum change.
- - **(a) is cleaner long-term:** add `ModuleRole::Layer` to the enum. The UI ROLE_EMOJI map gets a new entry (need to pick an emoji — 🪟 / 🎞️ / 🧱 are candidates, will ask the product owner). [check_specs.py](scripts/check/check_specs.py) might depend on the role list; verify.
+ - **(a) is cleaner long-term:** add `ModuleRole::Layer` to the enum. The UI ROLE_EMOJI map gets a new entry (need to pick an emoji — 🪟 / 🎞️ / 🧱 are candidates, will ask the product owner). [check_specs.py](moondeck/check/check_specs.py) might depend on the role list; verify.
**Plan picks (a)** because we're already changing the shape and adding a role is cheaper than a special-case in the UI. **One emoji to pick during implementation.**
@@ -212,13 +212,13 @@ Wait — that adds noise. Let me reconsider:
## Implementation order
-1. **Add `ModuleRole::Layer`** to [src/core/MoonModule.h](src/core/MoonModule.h). Update `roleName()`. Verify [scripts/check/check_specs.py](scripts/check/check_specs.py) doesn't have a hardcoded role list. Build to check for warnings.
+1. **Add `ModuleRole::Layer`** to [src/core/MoonModule.h](src/core/MoonModule.h). Update `roleName()`. Verify [moondeck/check/check_specs.py](moondeck/check/check_specs.py) doesn't have a hardcoded role list. Build to check for warnings.
2. **Rename `LayoutGroup` → `Layouts`** (class + file via `git mv` + factory key). Update all `#include`s, all `static_cast`, all references in tests + scenarios + spec. Build + run all tests; expect green (no behaviour change).
3. **Rename `DriverGroup` → `Drivers`** (same treatment).
4. **Add `class Layers`** in [src/light/Layers.h](src/light/Layers.h). Add `setLayouts()` to `Layer`. main.cpp creates `Layers` containing one `Layer`. Run all tests; live-verify with a desktop run that the pipeline still produces frames.
-5. **Add `start/end` controls to `Layer`** — uint16 (or int16 if available) with sensible bounds. Default = whole layout. `rebuildLUT()` honours them when not at default. Update [test_layer*.cpp](test) and add a test asserting "Layer with default start/end matches old Layer behaviour byte-for-byte."
+5. **Add `start/end` controls to `Layer`** — uint16 (or int16 if available) with sensible bounds. Default = whole layout. `rebuildLUT()` honours them when not at default. Update `test_layer*.cpp` and add a test asserting "Layer with default start/end matches old Layer behaviour byte-for-byte."
6. **UI emoji pick** for `ModuleRole::Layer` — ask the product owner. Add to `ROLE_EMOJI` map in [src/ui/app.js](src/ui/app.js).
-7. **Update specs** ([Layer.md](docs/moonmodules/light/Layer.md), new [Layouts.md](docs/moonmodules/light/Layouts.md), new [Layers.md](docs/moonmodules/light/Layers.md), new [Drivers.md](docs/moonmodules/light/drivers/Drivers.md), [architecture-light.md](docs/architecture-light.md), [plan.md](docs/plan.md), [README.md](README.md) if needed). Run [check_specs.py](scripts/check/check_specs.py).
+7. **Update specs** ([Layer.md](docs/moonmodules/light/Layer.md), new [Layouts.md](docs/moonmodules/light/Layouts.md), new [Layers.md](docs/moonmodules/light/Layers.md), new [Drivers.md](docs/moonmodules/light/drivers/Drivers.md), [architecture-light.md](docs/architecture-light.md), [plan.md](docs/plan.md), [README.md](README.md) if needed). Run [check_specs.py](moondeck/check/check_specs.py).
8. **Migration**: FilesystemModule deletes `.config/LayoutGroup.json` and `.config/DriverGroup.json` if present, logs a warning.
9. **All pre-commit gates 1–6** (build, ctest, scenarios, platform boundary, specs, ESP32). Reviewer agent (gate 7) after.
@@ -227,9 +227,9 @@ Wait — that adds noise. Let me reconsider:
- [ ] `cmake --build build` — zero warnings, builds clean.
- [ ] `ctest` — all unit tests pass, including the new `test_layers_container.cpp` cases.
- [ ] `./build/test/mm_scenarios` — all scenarios pass (after their `LayoutGroup`/`DriverGroup` type-name updates).
-- [ ] [check_platform_boundary.py](scripts/check/check_platform_boundary.py) — PASS.
-- [ ] [check_specs.py](scripts/check/check_specs.py) — all specs ok.
-- [ ] [build_esp32.py](scripts/build/build_esp32.py) — clean ESP32 build.
+- [ ] [check_platform_boundary.py](moondeck/check/check_platform_boundary.py) — PASS.
+- [ ] [check_specs.py](moondeck/check/check_specs.py) — all specs ok.
+- [ ] [build_esp32.py](moondeck/build/build_esp32.py) — clean ESP32 build.
- [ ] Live desktop run: `/api/types` shows `Layouts`, `Layers`, `Drivers` (no longer `LayoutGroup`, `DriverGroup`). `/api/state` shows the new tree shape. Effects render correctly through the new wiring. Tick time within run-to-run jitter of the previous commit.
- [ ] UI side-nav reads `Layouts`, `Layers`, `Drivers`. Cards under `Layers` contain one `Layer` with effects+modifiers inside. Drag-reorder still works within each container.
- [ ] One snapshot ESP32 run verifies no regression — same scenario, same FPS within jitter.
diff --git a/docs/history/plans/Plan-20260524 - Release 1.0 distribution - web installer + GitHub Releases.md b/docs/history/plans/Plan-20260524 - Release 1.0 distribution - web installer + GitHub Releases.md
index b865b407..f6e4fc53 100644
--- a/docs/history/plans/Plan-20260524 - Release 1.0 distribution - web installer + GitHub Releases.md
+++ b/docs/history/plans/Plan-20260524 - Release 1.0 distribution - web installer + GitHub Releases.md
@@ -20,7 +20,7 @@ Closed scope (decided before this plan, not revisited here):
- **Desktop binaries = macOS arm64 + Windows x64.** No Linux, no macOS x64.
- **No OTA in 1.0.** Users re-flash via Web Tools when a new release lands.
- **Tag matches `library.json` version.** CI fails fast on drift — maintainer bumps version, commits, tags as one act.
-- **MoonDeck developer flow unchanged.** `git clone` + `uv run scripts/moondeck.py` stays the dev bootstrap; the slow part is the prerequisite chain (uv, ESP-IDF), not the clone.
+- **MoonDeck developer flow unchanged.** `git clone` + `uv run moondeck/moondeck.py` stays the dev bootstrap; the slow part is the prerequisite chain (uv, ESP-IDF), not the clone.
## Architecture
@@ -29,7 +29,7 @@ git tag v1.0.0
└─> .github/workflows/release.yml
├─ verify-version (tag == library.json["version"]?)
├─ build-esp32 (matrix: esp32, esp32-eth, esp32-eth-wifi, esp32s3-n16r8)
- │ └─ scripts/build/build_esp32.py --board
+ │ └─ moondeck/build/build_esp32.py --board
├─ build-macos (macos-14, cmake Release, tar.gz)
├─ build-windows (windows-latest, cmake/MSVC Release, zip)
└─ release
@@ -68,14 +68,14 @@ The base file used to carry 7 Olimex-specific Eth lines (`CONFIG_ETH_USE_ESP32_E
**Files to edit:**
-- [scripts/build/build_esp32.py](../../scripts/build/build_esp32.py) — replace the `--profile` argument logic with a `BOARDS` dict + `--board` argument. `--profile` becomes a deprecated alias (`eth-only` → `esp32-eth`, `default` → `esp32`) for one release. Replace `profile_cmake_args()` with `board_cmake_args(board)`.
-- [scripts/build/build_esp32_ethonly.py](../../scripts/build/build_esp32_ethonly.py) — forward `--board esp32-eth` instead of `--profile eth-only`. Kept for any external scripting that already calls the filename.
+- [moondeck/build/build_esp32.py](../../moondeck/build/build_esp32.py) — replace the `--profile` argument logic with a `BOARDS` dict + `--board` argument. `--profile` becomes a deprecated alias (`eth-only` → `esp32-eth`, `default` → `esp32`) for one release. Replace `profile_cmake_args()` with `board_cmake_args(board)`.
+- [moondeck/build/build_esp32_ethonly.py](../../moondeck/build/build_esp32_ethonly.py) — forward `--board esp32-eth` instead of `--profile eth-only`. Kept for any external scripting that already calls the filename.
- [esp32/sdkconfig.defaults](../../esp32/sdkconfig.defaults) — remove the 7 Eth-block lines. File becomes board-neutral WiFi-default.
- [esp32/sdkconfig.defaults.eth](../../esp32/sdkconfig.defaults.eth) — renamed from `.olimex_gw`. Self-sufficient (carries every Eth setting the working Olimex build needs); comment names Olimex as the default pin map and points at the 2.0 PHY-runtime-config plan.
- [esp32/sdkconfig.defaults.esp32s3-n16r8](../../esp32/sdkconfig.defaults.esp32s3-n16r8) — renamed from `.esp32s3_n16r8` (hyphen instead of underscore — matches the board key). No content change.
- Profile-change marker: rename `esp32/build/.mm_profile` → `.mm_board`. Migrate on first run (if the legacy file exists, read it once, treat as the equivalent board, then write the new marker).
-- [scripts/moondeck.py](../../scripts/moondeck.py) — add `extra_args` forwarding (3 lines) so a config entry can pass static flags to its script.
-- [scripts/moondeck_config.json](../../scripts/moondeck_config.json) + [scripts/MoonDeck.md](../../scripts/MoonDeck.md) — replace the "Build" / "Build (Ethernet-only)" pair with four board buttons, each baking a `--board` arg via `extra_args`.
+- [moondeck/moondeck.py](../../moondeck/moondeck.py) — add `extra_args` forwarding (3 lines) so a config entry can pass static flags to its script.
+- [moondeck/moondeck_config.json](../../moondeck/moondeck_config.json) + [moondeck/MoonDeck.md](../../moondeck/MoonDeck.md) — replace the "Build" / "Build (Ethernet-only)" pair with four board buttons, each baking a `--board` arg via `extra_args`.
- docs/moonmodules/core/NetworkModule.md — update the Ethernet-only section to reference `--board esp32-eth`.
### Step 2 — Version-drift guard (0.5 h)
@@ -84,7 +84,7 @@ CI must verify `git tag == library.json["version"]` and fail before building. No
**Files to create:**
-- [scripts/build/verify_version.py](../../scripts/build/verify_version.py) — short script that reads `GITHUB_REF_NAME` (without leading `v`) and `library.json` `"version"`, fails the workflow if they differ.
+- [moondeck/ci/verify_version.py](../../moondeck/ci/verify_version.py) — short script that reads `GITHUB_REF_NAME` (without leading `v`) and `library.json` `"version"`, fails the workflow if they differ.
**Action at release time:**
@@ -96,7 +96,7 @@ Static-link what we can; accept dynamic libc++ on macOS (Apple doesn't ship a st
**Files to create / edit:**
-- [scripts/build/package_desktop.py](../../scripts/build/package_desktop.py) — new. Reads version from `library.json`, detects host platform, runs the right CMake invocation, packages.
+- [moondeck/ci/package_desktop.py](../../moondeck/ci/package_desktop.py) — new. Reads version from `library.json`, detects host platform, runs the right CMake invocation, packages.
- macOS arm64: `cmake -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES=arm64 && cmake --build build --config Release`. Tarball as `dist/projectMM-macos-arm64-vX.Y.Z.tar.gz` with the binary + a short `README.txt`.
- Windows x64: `cmake -B build -G "Visual Studio 17 2022" -A x64 -DCMAKE_BUILD_TYPE=Release -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded && cmake --build build --config Release`. Zip as `dist/projectMM-windows-x64-vX.Y.Z.zip`.
- [CMakeLists.txt](../../CMakeLists.txt) — gate the warning flags by compiler:
@@ -122,7 +122,7 @@ Don't hardcode the offset table — read from `esp32/build/flasher_args.json` pr
**Files to create:**
-- [scripts/build/generate_manifest.py](../../scripts/build/generate_manifest.py) — takes `--board --version --release-url --flasher-args --out `, writes the manifest JSON with parts ordered by offset.
+- [moondeck/build/generate_manifest.py](../../moondeck/build/generate_manifest.py) — takes `--board --version --release-url --flasher-args --out `, writes the manifest JSON with parts ordered by offset.
Schema:
@@ -168,7 +168,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - run: python scripts/build/verify_version.py
+ - run: python moondeck/ci/verify_version.py
build-esp32:
needs: verify-version
@@ -190,7 +190,7 @@ jobs:
esp_idf_version: v5.4
target: ${{ startsWith(matrix.board, 'esp32s3') && 'esp32s3' || 'esp32' }}
path: 'esp32'
- command: python ../scripts/build/build_esp32.py --board ${{ matrix.board }}
+ command: python ../moondeck/build/build_esp32.py --board ${{ matrix.board }}
- name: Stage artifacts
run: |
mkdir -p dist
@@ -209,7 +209,7 @@ jobs:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- - run: python scripts/build/package_desktop.py
+ - run: python moondeck/ci/package_desktop.py
- uses: actions/upload-artifact@v4
with: { name: desktop-macos, path: dist/ }
@@ -218,7 +218,7 @@ jobs:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- - run: python scripts/build/package_desktop.py
+ - run: python moondeck/ci/package_desktop.py
- uses: actions/upload-artifact@v4
with: { name: desktop-windows, path: dist/ }
@@ -237,7 +237,7 @@ jobs:
V=$(jq -r .version library.json)
BASE=https://github.com/${{ github.repository }}/releases/download/v$V
for B in esp32 esp32-eth esp32-eth-wifi esp32s3-n16r8; do
- python scripts/build/generate_manifest.py \
+ python moondeck/build/generate_manifest.py \
--board $B --version $V --release-url $BASE \
--flasher-args dist/flasher-$B.json --out dist/manifest-$B.json
done
@@ -372,23 +372,23 @@ These run as the per-release additions to CLAUDE.md's Event 3 (Release tag) gate
**New:**
- [.github/workflows/release.yml](../../.github/workflows/release.yml)
-- [scripts/build/package_desktop.py](../../scripts/build/package_desktop.py)
-- [scripts/build/generate_manifest.py](../../scripts/build/generate_manifest.py)
-- [scripts/build/verify_version.py](../../scripts/build/verify_version.py)
+- [moondeck/ci/package_desktop.py](../../moondeck/ci/package_desktop.py)
+- [moondeck/build/generate_manifest.py](../../moondeck/build/generate_manifest.py)
+- [moondeck/ci/verify_version.py](../../moondeck/ci/verify_version.py)
- docs/install/index.html
- docs/install/README.md
**Edited:**
-- [scripts/build/build_esp32.py](../../scripts/build/build_esp32.py) — add `--board`, deprecate `--profile`.
-- [scripts/build/build_esp32_ethonly.py](../../scripts/build/build_esp32_ethonly.py) — forwards to `--board esp32-eth`.
+- [moondeck/build/build_esp32.py](../../moondeck/build/build_esp32.py) — add `--board`, deprecate `--profile`.
+- [moondeck/build/build_esp32_ethonly.py](../../moondeck/build/build_esp32_ethonly.py) — forwards to `--board esp32-eth`.
- [esp32/sdkconfig.defaults](../../esp32/sdkconfig.defaults) — drop the 7 Eth lines.
- [esp32/sdkconfig.defaults.eth](../../esp32/sdkconfig.defaults.eth) — renamed from `.olimex_gw`. Self-sufficient (carries the full Olimex pin set).
- [esp32/sdkconfig.defaults.esp32s3-n16r8](../../esp32/sdkconfig.defaults.esp32s3-n16r8) — renamed from `.esp32s3_n16r8`. No content change.
- [CMakeLists.txt](../../CMakeLists.txt) — MSVC-gated warning flags + static MSVC runtime.
- [library.json](../../library.json) — bump `0.1.0` → `1.0.0` at release time.
-- [scripts/moondeck.py](../../scripts/moondeck.py) — `extra_args` forwarding.
-- [scripts/moondeck_config.json](../../scripts/moondeck_config.json) + [scripts/MoonDeck.md](../../scripts/MoonDeck.md) — four board buttons.
+- [moondeck/moondeck.py](../../moondeck/moondeck.py) — `extra_args` forwarding.
+- [moondeck/moondeck_config.json](../../moondeck/moondeck_config.json) + [moondeck/MoonDeck.md](../../moondeck/MoonDeck.md) — four board buttons.
- docs/moonmodules/core/NetworkModule.md — `--board esp32-eth` reference.
- [README.md](../../README.md) — Quick Start with installer URL.
- docs/building.md — boards table.
@@ -400,8 +400,8 @@ These run as the per-release additions to CLAUDE.md's Event 3 (Release tag) gate
## Verification
-- **Local board builds:** `python scripts/build/build_esp32.py --board esp32`, `--board esp32-eth`, `--board esp32-eth-wifi`, and `--board esp32s3-n16r8` all complete with zero warnings. Each writes `esp32/build/projectMM.bin` + `flasher_args.json` for the right chip.
-- **Local desktop:** `python scripts/build/package_desktop.py` on macOS produces a tarball under `dist/` that runs on a fresh Mac.
+- **Local board builds:** `python moondeck/build/build_esp32.py --board esp32`, `--board esp32-eth`, `--board esp32-eth-wifi`, and `--board esp32s3-n16r8` all complete with zero warnings. Each writes `esp32/build/projectMM.bin` + `flasher_args.json` for the right chip.
+- **Local desktop:** `python moondeck/ci/package_desktop.py` on macOS produces a tarball under `dist/` that runs on a fresh Mac.
- **Local installer (surface 1):** the C+ recipe in `docs/install/README.md` — pull branch CI artifacts with `gh run download`, serve locally, flash each of the four boards over USB through the install page.
- **RC dry run (surface 2):** push `v1.0.0-rcN` tag, all workflow jobs green, pre-release page populated with 22 files, Pages staging + deploy correctly skipped, manual flash per board succeeds against the rcN release URLs.
- **Real release (surface 3):** tag `v1.0.0`, Pages flips, the live `https://ewowi.github.io/projectMM/install/` flashes each board cleanly.
diff --git a/docs/history/plans/Plan-20260525 - Release-channel picker + first-boot WiFi provisioning.md b/docs/history/plans/Plan-20260525 - Release-channel picker + first-boot WiFi provisioning.md
index a205b21e..e1e7c996 100644
--- a/docs/history/plans/Plan-20260525 - Release-channel picker + first-boot WiFi provisioning.md
+++ b/docs/history/plans/Plan-20260525 - Release-channel picker + first-boot WiFi provisioning.md
@@ -22,7 +22,7 @@ projectMM has three installer surfaces, two of which don't exist in v3 yet:
Plan-18 builds all three with a coherent identity:
- **A shared release-channel picker JS module** powers both installers (Tracks 1+2): visitor (browser, or device-UI tab) picks **Stable** or **Pre-release (beta)** → release → board → click Install. Same code, two surfaces, one mental model.
-- **Improv WiFi over USB-serial** (Track 3) handles first-boot provisioning. Browser drives the protocol immediately after a flash via ESP Web Tools; a Python CLI (`scripts/build/improv_provision.py`) drives it for headless / rack / CI use. Same protocol, two transports.
+- **Improv WiFi over USB-serial** (Track 3) handles first-boot provisioning. Browser drives the protocol immediately after a flash via ESP Web Tools; a Python CLI (`moondeck/build/improv_provision.py`) drives it for headless / rack / CI use. Same protocol, two transports.
**Step 0 of v1 of this plan empirically falsified plan-17 risk #4**: GitHub release-asset URLs (both `github.com/.../releases/download/` and the `release-assets.githubusercontent.com` redirect target) return **no `Access-Control-Allow-Origin` headers**. Cross-origin browser fetches are blocked. WLED works around this with a third-party CORS proxy (`proxy.corsfix.com`); ESPHome self-hosts every binary; projectMM-v1 sidestepped the problem entirely by not having a web installer. v3 chooses **self-host on Pages** (Option 1 from the CORS replan discussion): the release workflow stages the last 5 stable + 5 prerelease releases' binaries into Pages content. End-of-line origin = same as the install page, no CORS.
@@ -51,7 +51,7 @@ Track 3 (Improv) closed scope:
- **Serial source**: UART0 only on every board. ESP32-S3-DevKitC-1's UART USB port works (UART0 routed to the on-board USB-UART bridge); the S3's native USB-Serial-JTAG port doesn't. AP-fallback remains the only path for users with USB-CDC-only connections.
- **Lifecycle**: always-on listener, no task suspension. Provision requests are rejected (with Improv's wrong-state error frame) when `platform::wifiStaConnected() == true`; scan + info requests stay available so a browser can identify a running device.
- **What it surfaces**: one read-only `provision_status` Control matching `FirmwareUpdateModule`'s shape. No buttons, no re-provision affordance — the protocol is the entry point.
-- **Rack / CI mode**: `scripts/build/improv_provision.py` — pyserial CLI speaking the Improv protocol. Single-port mode today (`--port + --ssid + --password`); a future `--from-list ` mode is a separate plan once v3 has a devicelist schema.
+- **Rack / CI mode**: `moondeck/build/improv_provision.py` — pyserial CLI speaking the Improv protocol. Single-port mode today (`--port + --ssid + --password`); a future `--from-list ` mode is a separate plan once v3 has a devicelist schema.
- **AP-fallback flow stays unchanged.** Improv adds a third credential-entry path alongside the AP fallback UI and the persistence-loaded values; all three converge on `NetworkModule::ssid_` / `password_`.
Prior art (the design isn't bespoke):
@@ -223,13 +223,13 @@ releasePicker.init({
Keep the SRI-pinned ESP Web Tools `