diff --git a/.vale.ini b/.vale.ini
index 4c2272d05..6661a9ae1 100644
--- a/.vale.ini
+++ b/.vale.ini
@@ -77,13 +77,14 @@ MoonLight.Weasel = error
MoonLight.NegatedHeading = error
MoonLight.SelfReference = error
-# The rules page must QUOTE the things it bans (an em-dash, "analyse", "this page", "really"), so
-# the four rules whose examples it carries stay off here. The rest are strict.
+# The rules page must QUOTE the things it bans (an em-dash, "analyse", "this page", "really", "today"), so
+# the five rules whose examples it carries stay off here. The rest are strict.
[docs/contributing/documentation-standards.md]
BasedOnStyles = MoonLight
MoonLight.EmDash = NO
MoonLight.Spelling = NO
MoonLight.SelfReference = NO
MoonLight.Weasel = NO
+MoonLight.Temporal = NO
MoonLight.SentenceLength = error
MoonLight.NegatedHeading = error
diff --git a/.vale/styles/MoonLight/Temporal.yml b/.vale/styles/MoonLight/Temporal.yml
new file mode 100644
index 000000000..3da3d57e5
--- /dev/null
+++ b/.vale/styles/MoonLight/Temporal.yml
@@ -0,0 +1,14 @@
+# documentation-standards.md: "Present tense only." These words date a sentence: they compare the system with a moment, which is history, so the sentence describes what exists without them.
+extends: existence
+message: "'%s' dates the sentence: describe what exists."
+level: error
+ignorecase: true
+tokens:
+ - today
+ - currently
+ - at the moment
+ - at present
+ - for now
+ - no longer
+ - anymore
+ - not yet
diff --git a/CLAUDE.md b/CLAUDE.md
index 9d973b698..0f692d27f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -186,7 +186,7 @@ flowchart LR
pc --> diff
always["always 💀 check_specs · 💀 check_prose · records, writes to the tree"]
md[".md build_docs --strict 🐢 💀 check_docgen · 🛸 test_host --python (catalog pages) check_taglines (front pages only)"]
- code["src/ or test/ 💀 check_code 🐢 · build_desktop 🐢 🛸 test_desktop 🐢 · run_scenario 🐢 💀 check_docgen (the headers it covers) 💀 check_esp32_built (not src/platform/desktop) 💀 build_desktop --no-jit 🐢 (MoonLive only) 💀 collect_kpi 🐢 · records, writes to the tree"]
+ code["src/ or test/ 💀 check_code · build_desktop 🐢 🛸 test_desktop 🐢 · run_scenario 🐢 💀 check_docgen (the headers it covers) 💀 check_esp32_built (not src/platform/desktop) 💀 build_desktop --no-jit 🐢 (MoonLive only) 💀 collect_kpi 🐢 · records, writes to the tree"]
web["src/ui or mooninstaller/ 🛸 test_host --js · 💀 check_devices"]
py["moondeck/ or moonlive/ 🛸 test_host --python · 💀 check_firmwares"]
board["the provisioning path, with a board attached 🛸 improv_smoke_test recommended, say so when skipped"]
diff --git a/CMakeLists.txt b/CMakeLists.txt
index fb02bb91f..d37da4db4 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -97,6 +97,11 @@ else()
# and that count may only fall, while each site stays a judgment (a driver may have to wait
# on hardware), so failing the build on them would block every other gate.
add_compile_options(-Wfunction-effects -Wno-error=function-effects)
+ # Each file's findings are kept beside its object, so the code report reads the whole tree after an incremental build instead of a clean one.
+ find_package(Python3 COMPONENTS Interpreter QUIET)
+ if(Python3_Interpreter_FOUND AND NOT CMAKE_CXX_COMPILER_LAUNCHER)
+ set(CMAKE_CXX_COMPILER_LAUNCHER "${Python3_EXECUTABLE};${CMAKE_SOURCE_DIR}/moondeck/build/diag_launcher.py")
+ endif()
endif()
endif()
@@ -126,6 +131,7 @@ find_program(UV_EXECUTABLE NAMES uv REQUIRED
add_library(mm_core STATIC
src/core/module/Control.cpp
src/core/util/JsonUtil.cpp
+ src/core/util/parse.cpp
src/core/util/ScratchBuffer.cpp
src/core/system/HttpServerModule.cpp
src/core/system/FilesystemModule.cpp
diff --git a/docs/assets/core/I2cScanModule.png b/docs/assets/core/I2cBusModule.png
similarity index 100%
rename from docs/assets/core/I2cScanModule.png
rename to docs/assets/core/I2cBusModule.png
diff --git a/docs/assets/deviceModels/quinled-dig-octa-32-8l.jpg b/docs/assets/deviceModels/esp32-16mb-quinled-dig-octa-32-8l.jpg
similarity index 100%
rename from docs/assets/deviceModels/quinled-dig-octa-32-8l.jpg
rename to docs/assets/deviceModels/esp32-16mb-quinled-dig-octa-32-8l.jpg
diff --git a/docs/assets/deviceModels/serg-minishield pinout.png b/docs/assets/deviceModels/esp32-16mb-serg-minishield-pinout.png
similarity index 100%
rename from docs/assets/deviceModels/serg-minishield pinout.png
rename to docs/assets/deviceModels/esp32-16mb-serg-minishield-pinout.png
diff --git a/docs/assets/deviceModels/serg-minishield.jpg b/docs/assets/deviceModels/esp32-16mb-serg-minishield.jpg
similarity index 100%
rename from docs/assets/deviceModels/serg-minishield.jpg
rename to docs/assets/deviceModels/esp32-16mb-serg-minishield.jpg
diff --git a/docs/assets/deviceModels/serg-unishield-v5 pinout.png b/docs/assets/deviceModels/esp32-16mb-serg-unishield-v5-pinout.png
similarity index 100%
rename from docs/assets/deviceModels/serg-unishield-v5 pinout.png
rename to docs/assets/deviceModels/esp32-16mb-serg-unishield-v5-pinout.png
diff --git a/docs/assets/deviceModels/serg-unishield-v5.jpg b/docs/assets/deviceModels/esp32-16mb-serg-unishield-v5.jpg
similarity index 100%
rename from docs/assets/deviceModels/serg-unishield-v5.jpg
rename to docs/assets/deviceModels/esp32-16mb-serg-unishield-v5.jpg
diff --git a/docs/assets/deviceModels/esp32-d0-16mb.jpg b/docs/assets/deviceModels/esp32-16mb-testbench.jpg
similarity index 100%
rename from docs/assets/deviceModels/esp32-d0-16mb.jpg
rename to docs/assets/deviceModels/esp32-16mb-testbench.jpg
diff --git a/docs/assets/deviceModels/generic-esp32-dev.jpg b/docs/assets/deviceModels/esp32-generic-dev.jpg
similarity index 100%
rename from docs/assets/deviceModels/generic-esp32-dev.jpg
rename to docs/assets/deviceModels/esp32-generic-dev.jpg
diff --git a/docs/assets/deviceModels/lolin-d32.jpg b/docs/assets/deviceModels/esp32-lolin-d32.jpg
similarity index 100%
rename from docs/assets/deviceModels/lolin-d32.jpg
rename to docs/assets/deviceModels/esp32-lolin-d32.jpg
diff --git a/docs/assets/deviceModels/mhc-v57-pro.jpg b/docs/assets/deviceModels/esp32-mhc-v57-pro.jpg
similarity index 100%
rename from docs/assets/deviceModels/mhc-v57-pro.jpg
rename to docs/assets/deviceModels/esp32-mhc-v57-pro.jpg
diff --git a/docs/assets/deviceModels/olimex-esp32-gateway.png b/docs/assets/deviceModels/esp32-olimex-gateway.png
similarity index 100%
rename from docs/assets/deviceModels/olimex-esp32-gateway.png
rename to docs/assets/deviceModels/esp32-olimex-gateway.png
diff --git a/docs/assets/deviceModels/esp32-p4-guition-m3.jpg b/docs/assets/deviceModels/esp32-p4-guition-m3.jpg
new file mode 100644
index 000000000..429987e01
Binary files /dev/null and b/docs/assets/deviceModels/esp32-p4-guition-m3.jpg differ
diff --git a/docs/assets/deviceModels/mhc-wled-esp32-p4-shield.jpg b/docs/assets/deviceModels/esp32-p4-mhc-wled-shield.jpg
similarity index 100%
rename from docs/assets/deviceModels/mhc-wled-esp32-p4-shield.jpg
rename to docs/assets/deviceModels/esp32-p4-mhc-wled-shield.jpg
diff --git a/docs/assets/deviceModels/esp32-p4-eth.png b/docs/assets/deviceModels/esp32-p4-waveshare-eth.png
similarity index 100%
rename from docs/assets/deviceModels/esp32-p4-eth.png
rename to docs/assets/deviceModels/esp32-p4-waveshare-eth.png
diff --git a/docs/assets/deviceModels/ESP32-P4-NANO-details-inter.jpg b/docs/assets/deviceModels/esp32-p4-waveshare-nano-details.jpg
similarity index 100%
rename from docs/assets/deviceModels/ESP32-P4-NANO-details-inter.jpg
rename to docs/assets/deviceModels/esp32-p4-waveshare-nano-details.jpg
diff --git a/docs/assets/deviceModels/waveshare-esp32-p4-nano.jpg b/docs/assets/deviceModels/esp32-p4-waveshare-nano.jpg
similarity index 100%
rename from docs/assets/deviceModels/waveshare-esp32-p4-nano.jpg
rename to docs/assets/deviceModels/esp32-p4-waveshare-nano.jpg
diff --git a/docs/assets/deviceModels/quinled-dig-next-2.jpg b/docs/assets/deviceModels/esp32-pico-quinled-dig-next-2.jpg
similarity index 100%
rename from docs/assets/deviceModels/quinled-dig-next-2.jpg
rename to docs/assets/deviceModels/esp32-pico-quinled-dig-next-2.jpg
diff --git a/docs/assets/deviceModels/quinled-dig-2-go.jpg b/docs/assets/deviceModels/esp32-quinled-dig-2-go.jpg
similarity index 100%
rename from docs/assets/deviceModels/quinled-dig-2-go.jpg
rename to docs/assets/deviceModels/esp32-quinled-dig-2-go.jpg
diff --git a/docs/assets/deviceModels/quinled-dig-quad-v3.jpg b/docs/assets/deviceModels/esp32-quinled-dig-quad-v3.jpg
similarity index 100%
rename from docs/assets/deviceModels/quinled-dig-quad-v3.jpg
rename to docs/assets/deviceModels/esp32-quinled-dig-quad-v3.jpg
diff --git a/docs/assets/deviceModels/quinled-dig-uno-v3.jpg b/docs/assets/deviceModels/esp32-quinled-dig-uno-v3.jpg
similarity index 100%
rename from docs/assets/deviceModels/quinled-dig-uno-v3.jpg
rename to docs/assets/deviceModels/esp32-quinled-dig-uno-v3.jpg
diff --git a/docs/assets/deviceModels/hpwit-shift-register-board.jpg b/docs/assets/deviceModels/esp32-s3-n16r8-hpwit-shift-register-board.jpg
similarity index 100%
rename from docs/assets/deviceModels/hpwit-shift-register-board.jpg
rename to docs/assets/deviceModels/esp32-s3-n16r8-hpwit-shift-register-board.jpg
diff --git a/docs/assets/deviceModels/esp32-s3-n16r8-lilygo-t7-s3.jpg b/docs/assets/deviceModels/esp32-s3-n16r8-lilygo-t7-s3.jpg
new file mode 100644
index 000000000..d83376b0d
Binary files /dev/null and b/docs/assets/deviceModels/esp32-s3-n16r8-lilygo-t7-s3.jpg differ
diff --git a/docs/assets/deviceModels/esp32-s3-n16r8-moonhub75.jpg b/docs/assets/deviceModels/esp32-s3-n16r8-moonhub75.jpg
new file mode 100644
index 000000000..95afcb473
Binary files /dev/null and b/docs/assets/deviceModels/esp32-s3-n16r8-moonhub75.jpg differ
diff --git a/docs/assets/deviceModels/esp32-s3-testbench.jpg b/docs/assets/deviceModels/esp32-s3-n16r8-testbench.jpg
similarity index 100%
rename from docs/assets/deviceModels/esp32-s3-testbench.jpg
rename to docs/assets/deviceModels/esp32-s3-n16r8-testbench.jpg
diff --git a/docs/assets/deviceModels/lightcrafter-16.jpg b/docs/assets/deviceModels/esp32-s3-n8r8-lightcrafter-16.jpg
similarity index 100%
rename from docs/assets/deviceModels/lightcrafter-16.jpg
rename to docs/assets/deviceModels/esp32-s3-n8r8-lightcrafter-16.jpg
diff --git a/docs/assets/deviceModels/esp32-s3-stephanelec-16p.jpg b/docs/assets/deviceModels/esp32-s3-n8r8-stephanelec-16p.jpg
similarity index 100%
rename from docs/assets/deviceModels/esp32-s3-stephanelec-16p.jpg
rename to docs/assets/deviceModels/esp32-s3-n8r8-stephanelec-16p.jpg
diff --git a/docs/assets/deviceModels/shelly.jpg b/docs/assets/deviceModels/esp32-shelly.jpg
similarity index 100%
rename from docs/assets/deviceModels/shelly.jpg
rename to docs/assets/deviceModels/esp32-shelly.jpg
diff --git a/docs/contributing/documentation-standards.md b/docs/contributing/documentation-standards.md
index 784ee390e..e9e1e136c 100644
--- a/docs/contributing/documentation-standards.md
+++ b/docs/contributing/documentation-standards.md
@@ -143,7 +143,7 @@ Two scales below a page. **A module** has exactly one reference page written and
- **No sentence whose job is tone.** Every sentence carries a fact the reader needs. Three shapes to cut: a second person used for effect rather than instruction, a flourish before any information arrives, and a rhetorical question the page then answers. Vale checks spelling, sentence length and weasel words, so this one is the writer's judgement and the reviewer's check.
- **Follow the [principles](../CLAUDE.md#principles).** Three bear on documentation directly:
- **Minimalism**: every fact has one home; history lives in git.
- - **Present tense only.** "No X anymore" narrates a removal, which is history. Describe the path that exists today.
+ - **Present tense only.** "No X anymore" narrates a removal, which is history. Describe the path that exists. Vale flags the words that date a sentence ("today", "currently", "no longer", "not yet").
- **Positive form only.** "Not", "never", "neither", "without", "un-" and "non-" are the alarm bells: a negation says everything a thing is not, which is no shape at all. A real constraint stays ("the DMA cannot read PSRAM at shift clock"); a bare absence goes.
- **Industry standards**: the textbook name for a thing, so a reader recognizes it without being taught our vocabulary. A bespoke choice carries its one-line reason where it appears.
- **Continuous improvement**: a doc describing what the code no longer does is a defect. Fix it in the change that opened the file, not in a sweep.
diff --git a/docs/explanation/architecture/mooncore.md b/docs/explanation/architecture/mooncore.md
index 1b041f22a..bf17789e8 100644
--- a/docs/explanation/architecture/mooncore.md
+++ b/docs/explanation/architecture/mooncore.md
@@ -50,7 +50,7 @@ A **service** is a MoonModule (role `ModuleRole::Service`) that bridges to the o
The defining line is the **data relationship, not the connector**: *does the module consume the light output buffer?* If yes it's a **driver** (ArtNet, DMX, SPI-LED all consume the buffer, differing only in transport; a DMX sender uses a UART/RS-485 transport but is a driver because it sends the rendered buffer). If no, it's a **service**.
-Services are **user-add/deletable children of the `Services` container**, the core-domain twin of the light pipeline's `Effects`/`Drivers`: a top-level container holding user-added children of one role. The firmware is identical whether or not the hardware is wired. The user adds the module when they solder a gyro on and removes it later, reusing the generic child add, replace, delete and persistence machinery; `Services` declares `acceptsChildRoles("service")`. Fixed device infrastructure lives under **System** instead, wired by code rather than user-added: identity, network, and the inspection tools Tasks and I2cScan. That is the System/Services split. Direction is per-module, not a role: a service may read (gyro), write (relay), or both, so one `Service` role spans the category. Each is a header-only or `.h`+`.cpp` core module under `src/core/`, reaches hardware only through a domain-neutral platform primitive (`platform::i2c*`, `platform::audioMic*`, …), and gets a spec in `docs/moonmodules/core/services.md` (enforced by `check_specs.py`). Most poll in `tick20ms` or `tick1s`. The exception is a service whose data an effect consumes *every frame*: [AudioService](../../moonmodules/core/moxygen/AudioService.md) reads + analyses its I²S microphone in `tick()` because the audio effects react per render tick, and its per-tick cost (one FFT) is part of the render budget. Automatic bus-probe detection is out of scope; the manual path is the foundation.
+Services are **user-add/deletable children of the `Services` container**, the core-domain twin of the light pipeline's `Effects`/`Drivers`: a top-level container holding user-added children of one role. The firmware is identical whether or not the hardware is wired. The user adds the module when they solder a gyro on and removes it later, reusing the generic child add, replace, delete and persistence machinery; `Services` declares `acceptsChildRoles("service")`. Fixed device infrastructure lives under **System** instead, wired by code rather than user-added: identity, network, the inspection tool Tasks, and the board's I2C bus. That is the System/Services split. Direction is per-module, not a role: a service may read (gyro), write (relay), or both, so one `Service` role spans the category. Each is a header-only or `.h`+`.cpp` core module under `src/core/`, reaches hardware only through a domain-neutral platform primitive (`platform::i2c*`, `platform::audioMic*`, …), and gets a spec in `docs/moonmodules/core/services.md` (enforced by `check_specs.py`). Most poll in `tick20ms` or `tick1s`. The exception is a service whose data an effect consumes *every frame*: [AudioService](../../moonmodules/core/moxygen/AudioService.md) reads and analyzes its I²S microphone in `tick()`, because the audio effects react per render tick. Its per-tick cost, one FFT, is part of the render budget. Automatic bus-probe detection is out of scope; the manual path is the foundation.
**An effect reads a service's data** through the shared-struct pull pattern from [Data exchange](moonmodule.md#data-exchange-between-modules), with no new mechanism. The service owns a small POD struct overwritten in place each poll or tick, and the consuming effect holds a `const` pointer to it. The first concrete case is audio: AudioService produces an `AudioFrame` (level + 16-band spectrum + peak) that [AudioSpectrumEffect](../../moonmodules/light/effects.md) and the other audio effects consume. It reaches the frame through a static `AudioService::latestFrame()` rather than a boot-time setter. That variation exists because an audio effect can be added through the UI *after* boot and must still find the one live mic (a setter only wired the boot instance). The active mic registers itself in `setup()` and clears the pointer in `release()`, so add/remove in any order returns either the live frame or a static silent one, never null. A service that only *displays* its readings (the gyro today) skips the consumer side entirely.
diff --git a/docs/moonmodules/core/services.md b/docs/moonmodules/core/services.md
index f0215d9d6..69f010c7f 100644
--- a/docs/moonmodules/core/services.md
+++ b/docs/moonmodules/core/services.md
@@ -23,7 +23,9 @@ A user-added Service: the audio source the audio-reactive effects consume. `mode
- `mode`: Local audio, Receive network or Simulate, each showing only its own controls below.
- `micMode`: (Local, I²S targets) `I2S` for a three-wire part, `PDM` for a two-wire one.
- `sckPin` / `wsPin` / `sdPin`: (Local, I²S targets) the bus GPIOs, unset until entered.
-- `mclkPin`: (Local, I²S targets) the master clock a line-in ADC may need; unset for a plain mic.
+- `mclkPin`: (Local, I²S targets) the master clock an ADC or codec needs; unset for a plain mic.
+- `codec`: (Local, I²S targets) `ES8311` for a mic behind that codec, else `none`.
+- `codecAddr`: (with a codec) its address on the [I2C bus](system.md#i2c-bus), in hex (`0x18`).
- `device`: (Local, desktop) the OS capture input, `default` following the system setting.
- `sampleRate`: (Local) mic/ADC sample rate.
- `levels`: (Local) who sets the display window: `manual`'s two sliders, or `automatic`, a learner.
diff --git a/docs/moonmodules/core/system.md b/docs/moonmodules/core/system.md
index 18f6fc0a2..a03ed1d95 100644
--- a/docs/moonmodules/core/system.md
+++ b/docs/moonmodules/core/system.md
@@ -8,7 +8,7 @@ The device's fixed infrastructure: identity, network, provisioning, firmware, an
### System
-The device's identity and vitals: name (behind mDNS `.local`, the SoftAP SSID, the DHCP hostname), uptime, heap and the tick it renders at. Its fixed inspection children (Tasks, I2C scan) hang beneath it.
+The device's identity and vitals: name (behind mDNS `.local`, the SoftAP SSID, the DHCP hostname), uptime, heap and the tick it renders at. Its fixed children (Tasks, the I2C bus, Pins) hang beneath it.
@@ -240,21 +240,19 @@ Known renames from [MIGRATING.md](../../reference/MIGRATING.md) apply before upl
Detail: [technical](moxygen/FileManagerModule.md)
-
+
-### I2C scan
+### I2C bus
-A fixed System module (wired-by-code, always present) that probes the I²C bus on a button press and reports the addresses found, a hardware bring-up tool. The bus pins default to unused (−1), so a board without an I²C device claims no GPIO for it; a board with a bus sets its pins via the catalog, or you type them for an ad-hoc scan. Passive until the scan button is pressed.
+A fixed System module (wired-by-code, always present) that owns the board's I²C bus: it opens the bus on its pins, every device on the bus attaches to it, and a scan probes it. A device names only its own address, such as AudioService's `codecAddr`; the wires are stated here, once per board. The pins default to unused (−1), so a board without an I²C device claims no GPIO.
-
+
-- `sda` / `scl`: the bus GPIOs, defaulting to −1 for unused.
-
-A board with a fixed bus injects its own through the catalog, or you type the pins for an ad-hoc scan. The classic Arduino-ESP32 pair is 21/22.
+- `sda` / `scl`: the bus GPIOs, −1 for unused; a board's catalog entry sets them.
- `scan`: a button; press to probe the bus now.
-- read-only: `result` (addresses found).
+- read-only: `result`, each address found, named by the module driving it: `0x18 Audio (ES8311)`.
-Detail: [technical](moxygen/I2cScanModule.md)
+Detail: [technical](moxygen/I2cBusModule.md)
diff --git a/docs/moonmodules/light/drivers.md b/docs/moonmodules/light/drivers.md
index 0d9be2a34..29df739ba 100644
--- a/docs/moonmodules/light/drivers.md
+++ b/docs/moonmodules/light/drivers.md
@@ -67,7 +67,8 @@ Plus the [shared controls](#shared-driver-controls) above:
- `pins`: data GPIO list: `18,17,16`, or ranges like `20-23`, mixed freely. Empty idles until set.
- `ledsPerPin`: lights per strand. Blank splits evenly, one number to all, a list per strand.
- `peripheral`: the DMA peripheral, filtered to what the chip supports. It divides the card.
-- `doubleBuffer`, `pinExpander`, `latchPin`, bus pins: shown per peripheral.
+- 🎚️ `doubleBuffer`: encode the next frame while this one clocks out, where supported.
+- `pinExpander`, `latchPin`, bus pins: shown per peripheral.
- read-only `frameTime`: what one frame costs on the wire, the headroom the pipeline has left.
- 🔧 `loopbackTest`: a TX to RX self-test, verdict in the status field.
- 🔧 `loopbackStrand` / `loopbackIntrusive`: which strand it tests, and whether it may disturb output.
@@ -90,11 +91,11 @@ Drives **HUB75 LED panels straight from the board's GPIO**, with no receiving ca
- `clk lat oe`: shift clock, latch, output enable.
- `peripheral`: `LCD_CAM` or `Parlio`, where the chip has both and the frame fits.
- `scanRate`: 1/8, 1/16 or 1/32, **read off the panel**, not calculated.
-- `bitDepth` (2 to 4): color precision against refresh and memory.
+- `bitDepth` (2 to 6, default 6): color levels against refresh and memory.
- `clockEdge`: `rising` or `falling`. Every pixel one column over means the other one.
- `refresh`: the **measured** rate. The number to report if a panel flickers.
-**New, and not yet run on a wall we own.** Built from the panel's documented behavior with its encoder pinned by [host tests](../../reference/tests/unit-tests.md#hub75driver), which is not hardware verification. Reports welcome: `refresh` plus your geometry is what makes one useful.
+**Run on one panel.** A [MoonHub75](https://moonmodules.org/projects/hardware/#moonhub75-pcb) drives a 64x64 1/32-scan panel at 6-bit and 1,508 Hz. Larger walls and other panel families wait on reports: `refresh` plus your geometry makes one useful.
Detail: [technical](moxygen/Hub75Driver.md) · encoder: [Hub75Slots](moxygen/Hub75Slots.md)
@@ -415,6 +416,6 @@ The [MoonHub75 PCB](https://moonmodules.org/projects/hardware/#moonhub75-pcb) is
**How a HUB75 panel works, in two facts.** It is *scanned*, not addressed: two rows light at once, the upper half-panel through R1/G1/B1 and the lower through R2/G2/B2, selected by a row address on A/B/C (and D/E on finer panels). The controller walks every scan row in turn and persistence of vision does the rest, so a 64-row panel has 32 scan rows and row `r` drives panel rows `r` and `r + 32` together.
-And brightness is *time*, not amplitude. A HUB75 pixel is a switch, on or off, so intensity comes from binary coded modulation: bit plane `p` is displayed for 2^p time units, and a value lights its planes for a total proportional to itself. That weighting is the peripheral's output-enable window, and it is not built yet, which is why the depth cap below exists. Either way the encoder stores each plane once. Emitting plane `p` 2^p times is the obvious reading and it is wrong: 255 passes at 8-bit is 1,060,800 bytes for a single 64x64 panel, where storing once is 33,280.
+And brightness is *time*, not amplitude. A HUB75 pixel is a switch, on or off, so intensity comes from binary coded modulation: bit plane `p` is displayed for 2^p time units, and a value lights its planes for a total proportional to itself. That weighting is the output-enable window, which the encoder sets per plane. Either way the encoder stores each plane once. Emitting plane `p` 2^p times is the obvious reading and it is wrong: 255 passes at 8-bit is 1,060,800 bytes for a single 64x64 panel, where storing once is 33,280.
-**What depth costs, and why it stops at 4.** Every bit plane is a full scan, so depth costs refresh and memory linearly, and each slot on the wire is 2 bytes because the address, latch and output-enable lines sit above bit 7 of a 16-bit word. One 64x64 panel at 1/32 scan is 16,640 bytes a frame at 4-bit. The planes are emitted once each rather than weighted for 2^p time, so bit 3 lights as long as bit 0 and a fifth plane would buy nothing the eye can find. The weighting is [backlogged](https://github.com/MoonModules/MoonLight/blob/main/docs/work/future/backlog-light.md), and the cap lifts with it.
+**What depth costs, and why brightness is time.** Every bit plane is a full scan, so depth costs refresh and memory linearly, and each slot on the wire is 2 bytes because the address, latch and output-enable lines sit above bit 7 of a 16-bit word. One 64x64 panel at 1/32 scan is 16,384 bytes a frame at 4-bit and 24,576 at 6-bit. A panel's chips only switch a column on or off, so each plane is lit twice as long as the one below and brightness shortens every plane's lit time instead of scaling the colors, which keeps every level at any brightness. The price is top brightness: full white lights about a third of the time at 6-bit. Repeating the upper planes instead, for full brightness and 8-bit depth, is [backlogged](https://github.com/MoonModules/MoonLight/blob/main/docs/work/future/backlog-light.md).
diff --git a/docs/moonmodules/light/supporting.md b/docs/moonmodules/light/supporting.md
index 1327eff71..1a9bff2b3 100644
--- a/docs/moonmodules/light/supporting.md
+++ b/docs/moonmodules/light/supporting.md
@@ -51,6 +51,7 @@ The container of driver modules, owning the shared buffer and the per-light outp
- `on`: master power, on by default.
Off scales the output to black while preserving `brightness`, so switching on restores the level. Every consumer drives it: the UI, infrared, the WLED app, Home Assistant and MQTT.
+- 🎚️ `relayPins`: GPIOs switching the LED power supply, closed while `on` and brightness > 0.
- `brightness`: global output brightness, multiplied with each driver's `localBrightness`.
- `palette`: what effects sample from, built in or [scripted](moonlive.md); settable by name too.
- `multicore`: run the output stage on the second core.
diff --git a/docs/reference/MIGRATING.md b/docs/reference/MIGRATING.md
index 6a82a7fe1..45dc57fdd 100644
--- a/docs/reference/MIGRATING.md
+++ b/docs/reference/MIGRATING.md
@@ -34,6 +34,20 @@ For a device still on old firmware (no Backup button yet), the [installer page](
## Unreleased
+### An audio codec is the device model's setting
+
+**Action: on an ESP32-S31 CoreBoard, apply its device model again, or set the I2C bus to SDA 51 / SCL 50 under System and AudioService's `codec` to `ES8311` with `mclkPin` 52.**
+
+The codec in front of a microphone is now AudioService's runtime configuration, where the S31 firmware used to assume it. One P4 firmware so serves every P4 board, with or without a codec.
+A board configured before this change has no codec set, and its microphone stays silent until it does.
+
+### The I2C scan is the board's I2C bus
+
+**Action: *nothing* for a restored backup, which carries the pins over; on a running device, set `sda` and `scl` again under System, I2cBus.**
+
+The scan module became the owner of the board's I2C bus, which every device on it shares. A codec now names only its address, and the wires are stated once.
+A device's saved scan pins sit under the old module type and are not read at boot.
+
### The access point always broadcasts its name and follows the radio's channel
**Action: *nothing*.**
diff --git a/docs/reference/hardware/esp32-s31-coreboard.md b/docs/reference/hardware/esp32-s31-coreboard.md
index 7189a94e2..b63f0712c 100644
--- a/docs/reference/hardware/esp32-s31-coreboard.md
+++ b/docs/reference/hardware/esp32-s31-coreboard.md
@@ -28,12 +28,13 @@ The onboard electret mic (J6) and speaker connect through an **ES8311 mono codec
| PA_CTRL | 57 | NS4150B amplifier enable |
> **SDA/SCL are GPIO51/GPIO50**, the *opposite* of what the schematic's `ESP_I2C_SDA` /
-> `ESP_I2C_SCL` net labels suggest. Bench-confirmed: the [I2cScanModule](../../moonmodules/core/moxygen/I2cScanModule.md)
+> `ESP_I2C_SCL` net labels suggest. Bench-confirmed: the [I2cBusModule](../../moonmodules/core/moxygen/I2cBusModule.md)
> (sda=51, scl=50 in the S31 catalog entry) finds the ES8311 ACK at 0x18; with 50/51 nothing
> ACKs. The other audio pins match the schematic + the chip's GPIO table (all of GPIO50–57 are
> plain I/O GPIOs routed through the matrix, no special-function conflict).
- **ES8311 I2C address: `0x18`** (the default; set by the `CE` pin tie).
+- The S31 catalog entry opens the I2C bus on SDA 51 / SCL 50 and sets AudioService's `codec` to `ES8311` with `mclkPin` 52. Applying the device model is what turns the codec on.
- Driven by Espressif's **`esp_codec_dev`** managed component (the ES8311 driver). The codec
needs **MCLK running before it answers I2C**, and `es8311_codec_cfg.mclk_div` must be set (256, the standard ratio) or `open` fails "unable to configure sample rate". So AudioService brings up the I2S channel (which drives MCLK on GPIO52) **before** the codec I2C config.
- **Mic-only path** (audio-reactive input) needs MCLK/SCLK/LRCK + ASDOUT (record) + the I2C bus.
@@ -116,6 +117,7 @@ The two pins in **one column are physically stacked**, so a 2-pin jumper cap bri
Wi-Fi 6 · Bluetooth (no separate BLE soc-flag) · IEEE 802.15.4 (Thread/Zigbee) · USB-OTG · GPSPI · TWAI (CAN) · RMT · Parlio · LCD_CAM i80 · on-chip EMAC · PSRAM. RISC-V dual-core.
-The S31 catalog entry drives **LEDs** (RMT on GPIO60) and **Wi-Fi 6**, and wires an
-**[I2cScanModule](../../moonmodules/core/moxygen/I2cScanModule.md)** on the codec bus (SDA 51 / SCL 50) for
-I2C bring-up. The **[AudioService](../../moonmodules/core/moxygen/AudioService.md)** ES8311 path is implemented, the codec seam configures the ES8311 over I2C (codec reachable, ACK at 0x18) and AudioService reads the I2S mic. End-to-end mic validation depends on confirming MCLK at GPIO52; the S31 entry keeps **Audio** under `planned` until that bench check passes, so the installer advertises only what's confirmed working. The other board capabilities (Ethernet, Bluetooth, SD, USB host, …) likewise live in the entry's `planned` list, see the S31 entry in `mooninstaller/deviceModels.json`.
+The S31 catalog entry drives **LEDs** (RMT on GPIO60), **Wi-Fi 6** and **Ethernet**, and opens the **[I2C bus](../../moonmodules/core/moxygen/I2cBusModule.md)** on the codec's pins (SDA 51 / SCL 50).
+The **[AudioService](../../moonmodules/core/moxygen/AudioService.md)** ES8311 path is bench-verified end to end: the codec answers at 0x18 with MCLK on GPIO52, and the mic level follows sound.
+The S31 entry therefore lists **Audio** as supported.
+The other board capabilities (Bluetooth, SD, USB host, …) live in the entry's `planned` list in `mooninstaller/deviceModels.json`.
diff --git a/docs/reference/hardware/mhc-wled-esp32-p4-shield.md b/docs/reference/hardware/mhc-wled-esp32-p4-shield.md
index b8bf7bfda..ea081d344 100644
--- a/docs/reference/hardware/mhc-wled-esp32-p4-shield.md
+++ b/docs/reference/hardware/mhc-wled-esp32-p4-shield.md
@@ -5,7 +5,7 @@ Terminal pinout and onboard features for the **MHC-WLED ESP32-P4 shield** (myhom
> **Board revision:** the terminal map and RS-485 wiring below are transcribed from a **V1** board (the builder's labeled V1 photos + schematics). The overview render is a **V2** render. Whether V2 keeps the identical GPIO↔terminal wiring is **not confirmed here**, so treat the map as V1-specific and verify against your own board's silkscreen if you have a different revision.
**Sources**
-- Overview render (board V2): [`docs/assets/deviceModels/mhc-wled-esp32-p4-shield.jpg`](../../assets/deviceModels/mhc-wled-esp32-p4-shield.jpg)
+- Overview render (board V2): [`docs/assets/deviceModels/esp32-p4-mhc-wled-shield.jpg`](../../assets/deviceModels/esp32-p4-mhc-wled-shield.jpg)
- Silkscreen (photographed) + the builder's V1 schematics and terminal maps (myhome-control / Wladi, 2026-07-16); the transcriptions below come from those. The schematics supersede the marketing render where they differ.
## Pinout
@@ -29,7 +29,7 @@ The LED-data outputs. Each terminal is `O` on the silkscreen; a level shif
| Terminal | GPIO | Note |
|---|---|---|
| O21 O20 O25 O5 O7 O23 O8 O27 | 21 20 25 5 7 23 8 27 | LED lanes (Parallel LED, peripheral `Parlio`, default), in terminal order |
-| O7 / O8 | 7 / 8 | ALSO the I²C bus (SDA 7 / SCL 8). Driving them as LED lanes means no I²C on this shield, which is why the catalog entry carries no I2cScan module. To get I²C back: move those two strands to `O22`/`O24` **and** set the driver's `pins` to `21,20,25,5,22,23,24,27` to match. Rewiring alone leaves the lanes still driving 7/8. |
+| O7 / O8 | 7 / 8 | ALSO the I²C bus (SDA 7 / SCL 8). Driving them as LED lanes means no I²C on this shield, which is why the catalog entry carries no I2cBus module. To get I²C back: move those two strands to `O22`/`O24` **and** set the driver's `pins` to `21,20,25,5,22,23,24,27` to match. Rewiring alone leaves the lanes still driving 7/8. |
| O3 | 3 | also on RS-485 (`A-3-B`) — see note below |
| O4 | 4 | also on RS-485 (`A-4-B`) — see note below |
| GND | — | ground for the output block |
@@ -72,6 +72,9 @@ Inputs are **diode-protected with a ~16 kHz low-pass filter** — designed for r
An onboard **PCM1808** ADC captures a line-in signal and outputs I²S to the P4. Catalog `AudioService`: **SCK 32 · WS 26 · SD 33 · MCLK 36** (the P4 is I²S master, so it drives MCLK).
+The P4-NANO's own microphone, behind its ES8311 codec, reads silent with the shield attached, though the codec answers on I²C and the I²S clocks run.
+On a bare P4-NANO the same settings hear the room. The cause is not known yet, so on the shield the line-in is the audio input.
+
### Ethernet (RMII)
The P4-NANO's RMII PHY: **MDC 31 · MDIO 52 · RST 51 · CLK 50 (external-in) · PHY addr 1**, `ethType` IP101, external clock. (Catalog `EthernetModule`.)
diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md
index 7885f79ba..ef1fb0109 100644
--- a/docs/reference/metrics/code.md
+++ b/docs/reference/metrics/code.md
@@ -4,7 +4,7 @@ Generated by [`moondeck/check/check_code.py`](../../../moondeck/check/check_code
Every function, block and file over a limit the [coding standards](../../contributing/coding-standards.md#static-checks) set. Current state only: the trend is this file's git history. The list only shrinks, per rule and in total, and a touched file clears its own rows.
-**1497 finding(s)** across 309 file(s). 3.06% of all lines are duplicated, 3791 lines.
+**1497 finding(s)** across 310 file(s). 3.04% of all lines are duplicated, 3791 lines.
## By rule
@@ -68,9 +68,9 @@ The next function worth simplifying is at the top of each file's list.
| `test/unit/light/unit_ParallelLedDriver_ring.cpp` | 20 | `lines 486-503, also in test/unit/light/unit_ParallelLedDriver_ring.cpp:432`, duplicated block 18 |
| `src/core/system/NetworkModule.h` | 19 | `NetworkModule::syncAccessPoint`, complex function 18 |
| `src/platform/esp32/platform_esp32.cpp` | 19 | `platform::httpRequest`, complex function 30 |
-| `src/core/services/AudioService.h` | 18 | `AudioService::syncEnsureSocket`, complex function 17 |
| `src/platform/esp32/platform_esp32_moon_i80.cpp` | 18 | `platform::createRingState`, complex function 32 |
| `test/unit/core/unit_PinsModule.cpp` | 18 | `lines 46-57, also in test/unit/light/unit_Hub75Driver_pins.cpp:66`, duplicated block 12 |
+| `src/core/services/AudioService.h` | 17 | `AudioService::syncEnsureSocket`, complex function 17 |
| `src/light/effects/PacmanEffect.h` | 16 | `lines 243-257, also in src/light/effects/FishTankEffect.h:244`, duplicated block 15 |
| `src/light/moonlive/MoonLiveBuiltins_light.h` | 15 | `moonlive::mm_light_escape`, complex function 13 |
| `src/platform/esp32/platform_esp32_ota.cpp` | 15 | `platform::otaWriteMoonBase`, complex function 33 |
@@ -111,7 +111,7 @@ The next function worth simplifying is at the top of each file's list.
| `src/core/system/WiFiModule.h` | 7 | `lines 352-359, also in src/light/drivers/FixtureProfilesModule.h:245`, duplicated block 8 |
| `src/core/util/JsonUtil.h` | 7 | `json::parseString`, complex function 34 |
| `src/light/effects/FishTankEffect.h` | 7 | `lines 244-258, also in src/light/effects/PacmanEffect.h:243`, duplicated block 15 |
-| `src/platform/esp32/platform_esp32_hub75.cpp` | 7 | `platform::hub75Init`, complex function 34 |
+| `src/platform/esp32/platform_esp32_hub75.cpp` | 7 | `platform::hub75Init`, complex function 32 |
| `src/platform/esp32/platform_esp32_parlio.cpp` | 7 | `lines 277-286, also in src/platform/esp32/platform_esp32_i80.cpp:425`, duplicated block 10 |
| `test/js/ui-ws-scheme.test.mjs` | 7 | `lines 10-22, also in test/js/ui-render-guard.test.mjs:13`, duplicated block 13 |
| `test/unit/core/unit_NetworkModule.cpp` | 7 | `lines 556-564, also in test/unit/core/unit_NetworkModule.cpp:190`, duplicated block 9 |
@@ -266,8 +266,8 @@ The next function worth simplifying is at the top of each file's list.
| `test/unit/light/unit_SingleColumnLayout.cpp` | 2 | `lines 12-25, also in test/unit/light/unit_GridLayout.cpp:8`, duplicated block 14 |
| `moondeck/check/check_clang_query.py` | 1 | `check_clang_query.py`, large file 1057 |
| `moondeck/check/check_docgen.py` | 1 | `check_docgen.py`, large file 1798 |
-| `moondeck/check/check_footprint.py` | 1 | `lines 31-41, also in moondeck/check/check_nonblocking.py:29`, duplicated block 11 |
-| `moondeck/check/check_nonblocking.py` | 1 | `lines 29-39, also in moondeck/check/check_footprint.py:31`, duplicated block 11 |
+| `moondeck/check/check_footprint.py` | 1 | `lines 31-41, also in moondeck/check/check_nonblocking.py:30`, duplicated block 11 |
+| `moondeck/check/check_nonblocking.py` | 1 | `lines 30-40, also in moondeck/check/check_footprint.py:31`, duplicated block 11 |
| `moondeck/ci/verify_version.py` | 1 | `lines 39-45, also in moondeck/scenario/run_scenario.py:33`, duplicated block 7 |
| `moondeck/docs/build_docs.py` | 1 | `lines 43-49, also in moondeck/scenario/run_scenario.py:33`, duplicated block 7 |
| `moondeck/docs/screenshot_modules.py` | 1 | `screenshot_modules.py`, large file 1228 |
@@ -286,6 +286,7 @@ The next function worth simplifying is at the top of each file's list.
| `src/core/services/OscPacket.h` | 1 | `osc::parse`, complex function 32 |
| `src/core/system/FileManagerModule.cpp` | 1 | `platform::filesystemUsed in tick1s`, blocking call on the render path 1 |
| `src/core/system/HttpServerModule.h` | 1 | `HttpServerModule::cancelBufferedSend`, deeply nested 4 |
+| `src/core/system/I2cBusModule.h` | 1 | `I2cBusModule::scan`, complex function 18 |
| `src/core/system/Sha1.h` | 1 | `sha1`, deeply nested 5 |
| `src/core/util/Addressing.h` | 1 | `sendAddressed`, complex function 11 |
| `src/core/util/CaptivePortal.h` | 1 | `captive::dnsReply`, complex function 21 |
@@ -324,13 +325,13 @@ The next function worth simplifying is at the top of each file's list.
| `src/light/util/E131Packet.h` | 1 | `parseE131Packet`, complex function 17 |
| `src/module_types.cpp` | 1 | `registerModuleTypes`, long function 148 |
| `src/platform/desktop/moonlive_asm_host.h` | 1 | `lines 36-43, also in src/platform/esp32/moonlive_asm_riscv.h:36`, duplicated block 8 |
-| `src/platform/desktop/platform_config.h` | 1 | `lines 80-94, also in src/platform/esp32/platform_config.h:246`, duplicated block 15 |
+| `src/platform/desktop/platform_config.h` | 1 | `lines 71-85, also in src/platform/esp32/platform_config.h:225`, duplicated block 15 |
| `src/platform/esp32/moonlive_asm_xtensa.h` | 1 | `lines 79-96, also in src/platform/esp32/moonlive_asm_riscv.h:74`, duplicated block 18 |
-| `src/platform/esp32/platform_config.h` | 1 | `lines 246-260, also in src/platform/desktop/platform_config.h:80`, duplicated block 15 |
+| `src/platform/esp32/platform_config.h` | 1 | `lines 225-239, also in src/platform/desktop/platform_config.h:71`, duplicated block 15 |
| `src/platform/esp32/platform_esp32_i2s.cpp` | 1 | `platform::audioMicInit`, complex function 11 |
| `src/platform/esp32/platform_esp32_tasks.cpp` | 1 | `platform::taskSnapshot`, complex function 12 |
-| `src/platform/platform.h` | 1 | `platform.h`, large file 1174 |
-| `src/ui/app.js` | 1 | `app.js`, large file 8953 |
+| `src/platform/platform.h` | 1 | `platform.h`, large file 1192 |
+| `src/ui/app.js` | 1 | `app.js`, large file 8976 |
| `src/ui/install-picker.js` | 1 | `install-picker.js`, large file 1048 |
| `src/ui/preview3d.js` | 1 | `preview3d.js`, large file 1431 |
| `test/js/installer-eth-only.test.mjs` | 1 | `lines 13-23, also in test/js/ui-mooncloud.test.mjs:6`, duplicated block 11 |
diff --git a/docs/reference/metrics/docgen.md b/docs/reference/metrics/docgen.md
index b4dfaa727..96f43e408 100644
--- a/docs/reference/metrics/docgen.md
+++ b/docs/reference/metrics/docgen.md
@@ -4,9 +4,9 @@ Generated by [`moondeck/check/check_docgen.py`](../../../moondeck/check/check_do
Every place the generated documentation breaks the shape [the standards](../../contributing/documentation-standards.md#the-card) define. Current state only: the trend is this file's git history. The list only shrinks.
-**0 error(s)** and **2477 warning(s)** across 181 page(s).
+**0 error(s)** and **2453 warning(s)** across 176 page(s).
-**5634 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says.
+**5633 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says.
An error is in a file that generates a documentation page, a header or a catalog page, so the finding is a defect in what gets published and it fails the gate. A warning is in an implementation file, which publishes nothing: its comments are a note to the next reader, worth fixing without being worth stopping a commit for. Both are counted here, because a warning nobody sees is a warning nobody fixes.
@@ -16,11 +16,11 @@ The split is temporary. It stages the sweep rather than ranking the two kinds of
| Rule | Errors | Warnings |
|---|---:|---:|
-| over-wide comment lines | 0 | 1769 |
-| multi-line comment blocks | 0 | 455 |
-| over-long sentences | 0 | 91 |
-| over-wide doc lines | 0 | 86 |
-| member deep dives | 0 | 76 |
+| over-wide comment lines | 0 | 1756 |
+| multi-line comment blocks | 0 | 447 |
+| over-long sentences | 0 | 90 |
+| over-wide doc lines | 0 | 85 |
+| member deep dives | 0 | 75 |
## By documentation area
@@ -28,8 +28,8 @@ The unit a sweep runs in: one summary page and the headers it owns, as the [hier
| Summary page | Errors | Warnings |
|---|---:|---:|
-| `(tests, no card)` | 0 | 1878 |
-| `platform/index.md` | 0 | 346 |
+| `(tests, no card)` | 0 | 1868 |
+| `platform/index.md` | 0 | 332 |
| `core/system.md` | 0 | 253 |
## Where the work is
@@ -38,7 +38,7 @@ Per area, since that is the unit a sweep runs in: the files ranked, then the rul
### (tests, no card)
-**0 error(s)** and **1878 warning(s)** across 156 file(s).
+**0 error(s)** and **1868 warning(s)** across 154 file(s).
| Findings | File | |
|---:|---|---|
@@ -52,13 +52,13 @@ Per area, since that is the unit a sweep runs in: the files ranked, then the rul
| 44 | `test/unit/core/unit_moonlive_codegen_xtensa.cpp` | warning |
| 42 | `test/unit/light/unit_ParallelSlots.cpp` | warning |
| 38 | `test/unit/light/unit_Drivers_rendersplit.cpp` | warning |
-| 1-36 each | *146 more warning files, 1215 findings* | |
+| 1-36 each | *144 more warning files, 1205 findings* | |
-By rule, warnings: 1422 over-wide comment lines, 208 multi-line comment blocks, 90 over-long sentences, 85 over-wide doc lines, 73 member deep dives.
+By rule, warnings: 1418 over-wide comment lines, 205 multi-line comment blocks, 89 over-long sentences, 84 over-wide doc lines, 72 member deep dives.
### platform/index.md
-**0 error(s)** and **346 warning(s)** across 21 file(s).
+**0 error(s)** and **332 warning(s)** across 18 file(s).
| Findings | File | |
|---:|---|---|
@@ -72,9 +72,9 @@ By rule, warnings: 1422 over-wide comment lines, 208 multi-line comment blocks,
| 19 | `src/platform/esp32/platform_esp32_parlio.cpp` | warning |
| 10 | `src/platform/esp32/platform_esp32_i2s.cpp` | warning |
| 10 | `src/platform/esp32/platform_esp32_improv.cpp` | warning |
-| 1-9 each | *11 more warning files, 44 findings* | |
+| 1-9 each | *8 more warning files, 30 findings* | |
-By rule, warnings: 227 over-wide comment lines, 119 multi-line comment blocks.
+By rule, warnings: 218 over-wide comment lines, 114 multi-line comment blocks.
### core/system.md
diff --git a/docs/reference/metrics/prose.md b/docs/reference/metrics/prose.md
index f24d756bd..b8c01c9a6 100644
--- a/docs/reference/metrics/prose.md
+++ b/docs/reference/metrics/prose.md
@@ -9,14 +9,15 @@ This number is the debt behind that gate, and it RATCHETS: a rise fails the chec
docgen.md does. A comment is prose wherever it lives, so a `//` in the web interface and a
`#` in a script are counted beside a `///` in a header.
-**2950 finding(s)** across 331 file(s).
+**3185 finding(s)** across 377 file(s).
| rule | findings |
|---|---|
-| `MoonLight.EmDash` | 1642 |
-| `MoonLight.Weasel` | 742 |
-| `MoonLight.SentenceLength` | 476 |
-| `MoonLight.Spelling` | 44 |
+| `MoonLight.EmDash` | 1636 |
+| `MoonLight.Weasel` | 738 |
+| `MoonLight.SentenceLength` | 474 |
+| `MoonLight.Temporal` | 248 |
+| `MoonLight.Spelling` | 43 |
| `MoonLight.SelfReference` | 33 |
| `MoonLight.NegatedHeading` | 13 |
@@ -24,23 +25,23 @@ docgen.md does. A comment is prose wherever it lives, so a `//` in the web inter
| file | findings |
|---|---|
-| `moondeck/MoonDeck.md` | 251 |
-| `moondeck/moondeck.py` | 165 |
-| `docs/how-to/home-automation.md` | 115 |
-| `mooninstaller/install-orchestrator.js` | 94 |
+| `moondeck/MoonDeck.md` | 256 |
+| `moondeck/moondeck.py` | 169 |
+| `docs/how-to/home-automation.md` | 117 |
+| `mooninstaller/install-orchestrator.js` | 96 |
| `docs/tutorials/build-your-own-moonmodules.md` | 92 |
-| `moondeck/moondeck_ui/app.js` | 85 |
-| `docs/how-to/building.md` | 82 |
-| `mooninstaller/README.md` | 75 |
-| `moondeck/check/check_clang_query.py` | 72 |
-| `mooninstaller/install.js` | 67 |
-| `src/ui/app.js` | 56 |
-| `src/ui/install-picker.js` | 50 |
-| `moondeck/check/check_nonblocking.py` | 49 |
-| `moondeck/docs/gen_api.py` | 49 |
-| `src/ui/preview3d.js` | 47 |
-| `moondeck/build/build_esp32.py` | 46 |
-| `docs/explanation/architecture/moonlight.md` | 42 |
+| `moondeck/moondeck_ui/app.js` | 92 |
+| `docs/how-to/building.md` | 88 |
+| `mooninstaller/README.md` | 74 |
+| `moondeck/check/check_clang_query.py` | 73 |
+| `src/ui/app.js` | 72 |
+| `mooninstaller/install.js` | 71 |
+| `src/ui/install-picker.js` | 55 |
+| `moondeck/docs/gen_api.py` | 53 |
+| `src/ui/preview3d.js` | 49 |
+| `moondeck/build/build_esp32.py` | 48 |
+| `moondeck/check/check_nonblocking.py` | 47 |
+| `docs/explanation/architecture/moonlight.md` | 45 |
| `moondeck/docs/mkdocs_hooks.py` | 40 |
-| `docs/reference/hardware/gpio-usage.md` | 36 |
-| `src/platform/esp32/platform_esp32_moon_i80.cpp` | 36 |
+| `docs/reference/testing.md` | 38 |
+| `src/platform/esp32/platform_esp32_moon_i80.cpp` | 37 |
diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json
index 43a3017ca..874cfcfb4 100644
--- a/docs/reference/metrics/repo-health.json
+++ b/docs/reference/metrics/repo-health.json
@@ -1,12 +1,12 @@
{
- "commit": "aace8390",
+ "commit": "35a2f0fb",
"flash": {
- "esp32s3-n16r8": 2239952,
- "esp32": 2189136,
- "esp32p4rev1-eth": 2150912,
+ "esp32s3-n16r8": 2243984,
+ "esp32": 2192048,
+ "esp32p4rev1-eth": 2173984,
"esp32p4rev1-eth-wifi": 2358896,
"esp32s3-n8r8": 2087168,
- "esp32s31": 2542032,
+ "esp32s31": 2545392,
"esp32-16mb": 2060368,
"esp32-eth": 1681040,
"esp32-wrover": 1843760,
@@ -14,22 +14,22 @@
"esp32p4rev3-eth": 2073888,
"esp32s3-zero": 2024192,
"esp32-pico": 2107168,
- "desktop-macos": 2167592,
+ "desktop-macos": 2151832,
"desktop-windows": 1925120
},
"measured": {
- "esp32p4rev1-eth": "2026-10-05",
- "esp32s31": "2026-10-05",
- "esp32": "2026-10-05",
+ "esp32p4rev1-eth": "2026-10-06",
+ "esp32s31": "2026-10-06",
+ "esp32": "2026-10-06",
"esp32-pico": "2026-09-09",
- "esp32s3-n16r8": "2026-10-05",
+ "esp32s3-n16r8": "2026-10-06",
"esp32s3-n8r8": "2026-09-08",
"esp32s3-zero": "2026-09-08",
"esp32-16mb": "2026-09-09",
"esp32p4rev1-eth-wifi": "2026-09-30",
"esp32-eth": "2026-09-11",
"esp32p4rev3-eth": "2026-09-29",
- "desktop-macos": "2026-10-05",
+ "desktop-macos": "2026-10-06",
"desktop-windows": "2026-09-30"
},
"perf": {
@@ -42,8 +42,8 @@
"desktop-macos": {
"p50": 5,
"p95": 462,
- "n": 12,
- "last": "2026-10-05"
+ "n": 16,
+ "last": "2026-10-06"
},
"esp32s3-n16r8": {
"p50": 2073,
@@ -108,8 +108,8 @@
"desktop-macos": {
"p50": 1,
"p95": 555,
- "n": 13,
- "last": "2026-10-05"
+ "n": 17,
+ "last": "2026-10-06"
},
"esp32s3-n16r8": {
"p50": 2505,
@@ -483,9 +483,9 @@
"Drivers_output_and_brightness": {
"desktop-macos": {
"p50": 7,
- "p95": 10,
+ "p95": 15,
"n": 32,
- "last": "2026-10-05"
+ "last": "2026-10-06"
},
"desktop-windows": {
"p50": 4,
@@ -509,9 +509,9 @@
"Effects_pipeline_builds_and_renders": {
"desktop-macos": {
"p50": 6,
- "p95": 16,
+ "p95": 11,
"n": 32,
- "last": "2026-10-05"
+ "last": "2026-10-06"
},
"desktop-windows": {
"p50": 4,
@@ -525,7 +525,7 @@
"p50": 1,
"p95": 2,
"n": 32,
- "last": "2026-10-05"
+ "last": "2026-10-06"
},
"desktop-windows": {
"p50": 4,
@@ -549,9 +549,9 @@
"Effects_teardown_under_a_running_pipeline": {
"desktop-macos": {
"p50": 7,
- "p95": 28,
+ "p95": 18,
"n": 32,
- "last": "2026-10-05"
+ "last": "2026-10-06"
},
"esp32s3-n16r8": {
"p50": 9080,
@@ -575,17 +575,17 @@
"Layers_stack_and_blend_live": {
"desktop-macos": {
"p50": 267,
- "p95": 697,
+ "p95": 368,
"n": 32,
- "last": "2026-10-05"
+ "last": "2026-10-06"
}
},
"Layouts_resize_reallocates_live": {
"desktop-macos": {
"p50": 10,
- "p95": 22,
+ "p95": 19,
"n": 32,
- "last": "2026-10-05"
+ "last": "2026-10-06"
},
"desktop-windows": {
"p50": 93,
@@ -609,9 +609,9 @@
"Modifiers_reshape_the_mapping": {
"desktop-macos": {
"p50": 7,
- "p95": 20,
+ "p95": 11,
"n": 32,
- "last": "2026-10-05"
+ "last": "2026-10-06"
},
"esp32s3-n16r8": {
"p50": 10350,
@@ -635,9 +635,9 @@
"Palettes_a_script_drives_the_effects": {
"desktop-macos": {
"p50": 7,
- "p95": 204,
- "n": 23,
- "last": "2026-10-05"
+ "p95": 122,
+ "n": 27,
+ "last": "2026-10-06"
}
}
},
@@ -647,15 +647,15 @@
"scenario_p50": {
"Effects_pipeline_builds_and_renders": {
"p50": 6,
- "p95": 16,
+ "p95": 11,
"n": 32,
- "last": "2026-10-05"
+ "last": "2026-10-06"
},
"Layouts_resize_reallocates_live": {
"p50": 10,
- "p95": 22,
+ "p95": 19,
"n": 32,
- "last": "2026-10-05"
+ "last": "2026-10-06"
}
}
},
@@ -679,53 +679,53 @@
}
},
"loc": {
- "core": 25586,
- "light": 31812,
- "platform": 17158,
- "ui": 12202,
- "test": 62376,
- "moondeck": 30382
+ "core": 25734,
+ "light": 31845,
+ "platform": 17198,
+ "ui": 12227,
+ "test": 62726,
+ "moondeck": 30424
},
"comments": {
"core": {
- "lines": 6157,
+ "lines": 6191,
"ratio": 0.266
},
"light": {
- "lines": 7640,
+ "lines": 7649,
"ratio": 0.269
},
"platform": {
- "lines": 3624,
- "ratio": 0.235
+ "lines": 3613,
+ "ratio": 0.234
},
"ui": {
- "lines": 3463,
+ "lines": 3469,
"ratio": 0.3
},
"test": {
- "lines": 6916,
+ "lines": 6923,
"ratio": 0.128
},
"moondeck": {
- "lines": 4967,
+ "lines": 4959,
"ratio": 0.186
}
},
"tests": {
- "cases": 2303,
+ "cases": 2319,
"scenarios": 30
},
"docs": {
- "md_files": 141,
- "md_lines": 30305,
- "plans_files": 41,
- "backlog_lines": 3265,
+ "md_files": 142,
+ "md_lines": 30366,
+ "plans_files": 42,
+ "backlog_lines": 3247,
"lessons_lines": 532,
"claude_md_lines": 283
},
"complexity": {
- "functions": 4105,
+ "functions": 4143,
"over_threshold": 335,
"worst_ccn": 128
}
diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md
index 5db93ab9c..1c149ebac 100644
--- a/docs/reference/metrics/repo-health.md
+++ b/docs/reference/metrics/repo-health.md
@@ -1,6 +1,6 @@
# Repo health
-Measured at `aace8390`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.**
+Measured at `35a2f0fb`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.**
Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human.
@@ -8,20 +8,20 @@ Current state only; the trend is this file's git history (`git log -p docs/refer
| Metric | v6.0.0 | Now | Change |
|---|---:|---:|---:|
-| flash desktop-macos | 1,963 KB | 2,117 KB | +153.8 KB (+7.8%) |
-| flash esp32 | 2,061 KB | 2,138 KB | +76.6 KB (+3.7%) |
-| flash esp32p4rev1-eth | 2,022 KB | 2,100 KB | +78.8 KB (+3.9%) |
-| flash esp32s3-n16r8 | 2,111 KB | 2,187 KB | +76.0 KB (+3.6%) |
-| flash esp32s31 | 2,395 KB | 2,482 KB | +87.0 KB (+3.6%) |
+| flash desktop-macos | 1,963 KB | 2,101 KB | +138.4 KB (+7.0%) |
+| flash esp32 | 2,061 KB | 2,141 KB | +79.5 KB (+3.9%) |
+| flash esp32p4rev1-eth | 2,022 KB | 2,123 KB | +101.3 KB (+5.0%) |
+| flash esp32s3-n16r8 | 2,111 KB | 2,191 KB | +79.9 KB (+3.8%) |
+| flash esp32s31 | 2,395 KB | 2,486 KB | +90.3 KB (+3.8%) |
| tick Effects_pipeline_builds_and_renders (desktop-macos) | 7 µs | 6 µs | -1 (-14.3%) |
| tick Layouts_resize_reallocates_live (desktop-macos) | 12 µs | 10 µs | -2 (-16.7%) |
-| lines core | 22,299 | 25,586 | +3,287 (+14.7%) |
-| lines light | 31,054 | 31,812 | +758 (+2.4%) |
-| lines platform | 16,798 | 17,158 | +360 (+2.1%) |
-| lines ui | 11,394 | 12,202 | +808 (+7.1%) |
-| lines test | 57,759 | 62,376 | +4,617 (+8.0%) |
-| lines moondeck | 29,325 | 30,382 | +1,057 (+3.6%) |
-| unit tests | 2,120 | 2,303 | +183 (+8.6%) |
+| lines core | 22,299 | 25,734 | +3,435 (+15.4%) |
+| lines light | 31,054 | 31,845 | +791 (+2.5%) |
+| lines platform | 16,798 | 17,198 | +400 (+2.4%) |
+| lines ui | 11,394 | 12,227 | +833 (+7.3%) |
+| lines test | 57,759 | 62,726 | +4,967 (+8.6%) |
+| lines moondeck | 29,325 | 30,424 | +1,099 (+3.7%) |
+| unit tests | 2,120 | 2,319 | +199 (+9.4%) |
| scenarios | 21 | 30 | +9 (+42.9%) |
Against the release `v6.0.0`, the fixed point a user compares with; the tables below compare against the last commit. A firmware not rebuilt since `v6.0.0` is left out, and a measure the release did not record yet, such as the code report, joins from the next one.
@@ -30,20 +30,20 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b
| Target | Flash | Capacity | Used | Built |
|---|---:|---:|---:|:--:|
-| desktop-macos | 2,117 KB | - | - | yes |
-| desktop-windows | 1,880 KB | - | - | carried 5d |
-| esp32 | 2,138 KB (+224 B) ⚠ | 2,496 KB | 86% | yes |
-| esp32-16mb | 2,012 KB | 4,096 KB | 49% | **STALE 26d** |
-| esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 24d** |
-| esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 26d** |
+| desktop-macos | 2,101 KB (−16 KB) ✓ | - | - | yes |
+| desktop-windows | 1,880 KB | - | - | carried 6d |
+| esp32 | 2,141 KB (+208 B) ⚠ | 2,496 KB | 86% | yes |
+| esp32-16mb | 2,012 KB | 4,096 KB | 49% | **STALE 27d** |
+| esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 25d** |
+| esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 27d** |
| esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) |
-| esp32p4rev1-eth | 2,100 KB (+144 B) ⚠ | 4,096 KB | 51% | yes |
-| esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | carried 5d |
-| esp32p4rev3-eth | 2,025 KB | 4,096 KB | 49% | carried 6d |
-| esp32s3-n16r8 | 2,187 KB (+80 B) ⚠ | 4,096 KB | 53% | yes |
-| esp32s3-n8r8 | 2,038 KB | 3,072 KB | 66% | **STALE 27d** |
-| esp32s3-zero | 1,977 KB | 2,496 KB | 79% | **STALE 27d** |
-| esp32s31 | 2,482 KB (+176 B) ⚠ | 4,096 KB | 61% | yes |
+| esp32p4rev1-eth | 2,123 KB (+640 B) ⚠ | 4,096 KB | 52% | yes |
+| esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | carried 6d |
+| esp32p4rev3-eth | 2,025 KB | 4,096 KB | 49% | **STALE 7d** |
+| esp32s3-n16r8 | 2,191 KB (+416 B) ⚠ | 4,096 KB | 54% | yes |
+| esp32s3-n8r8 | 2,038 KB | 3,072 KB | 66% | **STALE 28d** |
+| esp32s3-zero | 1,977 KB | 2,496 KB | 79% | **STALE 28d** |
+| esp32s31 | 2,486 KB (+512 B) ⚠ | 4,096 KB | 61% | yes |
| qemu | 1,351 KB | - | - | carried (age?) |
`Built: yes` was measured this run. `carried (age?)` was not rebuilt either and predates this record, so its age is unknown: it dates itself on the next build. `carried Nd` was NOT rebuilt and its number is N days old, so an absent delta says nothing about the change. **STALE** marks a carry older than 7 days: the number has gone unchecked long enough that growth will surface later as one jump, blamed on whichever commit happens to rebuild that target. `Used` is against the app slot in the firmware's own partition table.
@@ -52,17 +52,17 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b
| Target | Net | New | Removed | Grown | Shrunk | Other | Since |
|---|---:|---:|---:|---:|---:|---:|---|
-| esp32 | +0.2 KB | +0.0 KB | +0.0 KB | +0.3 KB | -0.1 KB | +0.0 KB | `aace8390` |
-| esp32p4rev1-eth | +0.1 KB | +0.0 KB | +0.0 KB | +0.2 KB | -0.1 KB | -0.0 KB | `aace8390` |
-| esp32s3-n16r8 | +0.1 KB | +0.0 KB | +0.0 KB | +0.2 KB | -0.1 KB | -0.0 KB | `aace8390` |
-| esp32s31 | +0.2 KB | +0.0 KB | +0.0 KB | +0.2 KB | -0.1 KB | +0.0 KB | `aace8390` |
+| esp32 | +0.2 KB | +0.0 KB | +0.0 KB | +0.3 KB | -0.1 KB | -0.0 KB | `35a2f0fb` |
+| esp32p4rev1-eth | +0.6 KB | +0.0 KB | +0.0 KB | +0.9 KB | -0.4 KB | +0.0 KB | `35a2f0fb` |
+| esp32s3-n16r8 | +0.4 KB | +0.0 KB | +0.0 KB | +0.5 KB | -0.1 KB | +0.0 KB | `35a2f0fb` |
+| esp32s31 | +0.5 KB | +0.0 KB | +0.0 KB | +0.5 KB | -0.1 KB | +0.0 KB | `35a2f0fb` |
Largest moves:
-- **esp32**: `mm::ui::appJs` +182 B, `mm::registerModuleTypes()` +68 B, `_ZN2mm13ModuleFactory12registerTypeINS_13NetworkModuleEEEbPK` -38 B, `mm::NetworkModule::tick1s()` -31 B, `mm::Drivers::applyRelay()` +20 B
-- **esp32p4rev1-eth**: `mm::ui::appJs` +182 B, `mm::registerModuleTypes()` -74 B, `mm::Drivers::applyRelay()` +36 B, `mm::NetworkModule::tick1s()` -16 B, `mm::ui::installPickerJs` +15 B
-- **esp32s3-n16r8**: `mm::ui::appJs` +182 B, `_ZN2mm13ModuleFactory12registerTypeINS_16RubiksCubeEffectEEE` -108 B, `mm::NetworkModule::tick1s()` -27 B, `mm::Drivers::applyRelay()` +20 B, `mm::ui::installPickerJs` +15 B
-- **esp32s31**: `mm::ui::appJs` +182 B, `mm::registerModuleTypes()` -68 B, `mm::Drivers::applyRelay()` +36 B, `mm::ui::installPickerJs` +15 B, `bool mm::ModuleFactory::registerType(char const` +4 B
+- **esp32**: `mm::ui::appJs` +137 B, `mm::HttpServerModule::streamFsFile(mm::platform::TcpConnecti` +65 B, `mm::HttpServerModule::handleListPatchRow(mm::platform::TcpCo` -45 B, `_ZN2mm13ModuleFactory12registerTypeINS_13NetworkModuleEEEbPK` -38 B, `mm::DriverBase::rebuildCorrection(unsigned char)` +32 B
+- **esp32p4rev1-eth**: `mm::DriverBase::rebuildCorrection(unsigned char)` -348 B, `mm::TetrixEffect::tick()` +144 B, `mm::ui::appJs` +137 B, `mm::Hub75Driver::tick()` +106 B, `bool mm::ModuleFactory::registerType(c` +100 B
+- **esp32s3-n16r8**: `mm::TetrixEffect::tick()` +144 B, `mm::ui::appJs` +137 B, `_ZN2mm13ModuleFactory12registerTypeINS_16RubiksCubeEffectEEE` -108 B, `mm::Hub75Driver::tick()` +96 B, `mm::HttpServerModule::handleListPatchRow(mm::platform::TcpCo` +45 B
+- **esp32s31**: `mm::ui::appJs` +137 B, `mm::Hub75Driver::tick()` +106 B, `mm::platform::hub75Init(mm::platform::Hub75Handle&, mm::plat` +84 B, `mm::registerModuleTypes()` -68 B, `mm::RubiksCubeEffect::init(mm::draw::Canvas const&, short, s` +62 B
What this commit's flash went to, symbol by symbol against the measurement at `Since`. New is new functionality, removed and shrunk are what a cleanup saved, and other is what no symbol owns, such as merged strings and alignment. A rename reads as removed plus new, which the largest moves expose.
@@ -70,7 +70,7 @@ What this commit's flash went to, symbol by symbol against the measurement at `S
| Target | Tick | FPS |
|---|---:|---:|
-| desktop-macos | 1 µs | 1,000,000 |
+| desktop-macos | 1 µs (−1 µs) ✓ | 1,000,000 (+500,000) ✓ |
| desktop-windows | 4 µs | 250,000 |
| esp32 | 8,354 µs | 119 |
@@ -87,7 +87,7 @@ What this commit's flash went to, symbol by symbol against the measurement at `S
| Firmware_reports_what_is_running | 29 | 1,827 ? | - | 8,659 ? | - | - | - | - | 215 ? | - |
| Fluid_solver | 218 | 6,950,405 ? | 45,974 ? | 102,058 ? | 71,150 ? | 66,874 ? | - | - | 3,371 ? | 4,661,597 ? |
| GridLayout_resize | 120 | 219 ? | 1,352 ? | 18,414 ? | 7,218 ? | 8,919 ? | 95,771 ? | 82,231 ? | 2,005 ? | 6,735,719 ? |
-| Layers_stack_and_blend_live | 267 (−1) ✓ | - | - | - | - | - | - | - | - | - |
+| Layers_stack_and_blend_live | 267 | - | - | - | - | - | - | - | - | - |
| Layouts_resize_reallocates_live | 10 | 93 ? | - | 32,399 ? | - | - | - | - | 579 ? | - |
| Modifiers_reshape_the_mapping | 7 | 29 ? | - | 10,350 ? | - | - | - | - | 273 ? | - |
| Network_hardware_reconfigures_live | 21 ? | 2,834 ? | - | 8,692 ? | - | - | - | - | 237 ? | - |
@@ -106,8 +106,8 @@ Microseconds. `?` marks a cell backed by fewer than 4 samples, which is a first
| Scenario | p50 | p95 | n |
|---|---:|---:|---:|
-| Effects_pipeline_builds_and_renders | 6 µs | 16 µs | 32 |
-| Layouts_resize_reallocates_live | 10 µs | 22 µs | 32 |
+| Effects_pipeline_builds_and_renders | 6 µs | 11 µs | 32 |
+| Layouts_resize_reallocates_live | 10 µs | 19 µs | 32 |
These build a bare pipeline with no optional modules, so a change here is a change in the pipeline itself rather than in what was measured. A new module belongs in an advanced scenario, which keeps its own numbers.
@@ -124,36 +124,36 @@ These build a bare pipeline with no optional modules, so a change here is a chan
| Area | Lines | Comments | Comment share |
|---|---:|---:|---:|
-| core | 25,586 (−4) ✓ | 6,157 | 26.6 % |
-| light | 31,812 (+13) ⚠ | 7,640 | 26.9 % |
-| platform | 17,158 (+9) ⚠ | 3,624 | 23.5 % |
-| ui | 12,202 (+4) ⚠ | 3,463 | 30.0 % |
-| test | 62,376 (+6) ⚠ | 6,916 | 12.8 % |
-| moondeck | 30,382 (+2) ⚠ | 4,967 | 18.6 % |
+| core | 25,734 | 6,191 | 26.6 % |
+| light | 31,845 (+24) ⚠ | 7,649 | 26.9 % |
+| platform | 17,198 (−1) ✓ | 3,613 | 23.4 % |
+| ui | 12,227 (+7) ⚠ | 3,469 | 30.0 % |
+| test | 62,726 (+88) ⚠ | 6,923 | 12.8 % |
+| moondeck | 30,424 | 4,959 | 18.6 % |
## Tests
| Kind | Count |
|---|---:|
-| unit cases | 2,303 |
+| unit cases | 2,319 (+6) ✓ |
| scenarios | 30 |
## Complexity
| Metric | Value |
|---|---:|
-| functions | 4,105 (+1) ✓ |
-| over threshold | 335 (−1) ✓ |
+| functions | 4,143 (+4) ✓ |
+| over threshold | 335 |
| worst CCN | 128 |
## Documentation
| Metric | Value |
|---|---:|
-| markdown files | 141 |
-| markdown lines | 30,305 (+13) ⚠ |
-| plan files | 41 |
-| backlog lines | 3,265 |
+| markdown files | 142 |
+| markdown lines | 30,366 (−10) ✓ |
+| plan files | 42 |
+| backlog lines | 3,247 (−25) ✓ |
| lessons lines | 532 |
| CLAUDE.md lines | 283 |
diff --git a/docs/work/future/backlog-core.md b/docs/work/future/backlog-core.md
index 0495d71cc..7f798e417 100644
--- a/docs/work/future/backlog-core.md
+++ b/docs/work/future/backlog-core.md
@@ -590,6 +590,13 @@ Left out of the plan that makes Ethernet, WiFi and the access point Network subm
## Architecture
+### A second I2C bus, once a board wires one (2026-10-06)
+
+`I2cBusModule` drives one bus on controller 0, which covers every catalog board: one bus carries about 112 addresses, so a codec, a sensor and a display share it.
+A board needs a second bus for two chips on the same fixed address, devices at different voltages or pull-ups, or a long cable run kept off the onboard devices.
+The shape then: the module becomes addable with a controller-number control, the platform keeps one bus per controller, and a device on a bus (`AudioService`'s codec first) gains a bus selector defaulting to 0, so the catalog's boards keep working unchanged.
+The trigger is a real board; a touch controller or camera on its own pins next to the codec is the likely first, the Guition M3's touch wiring being unchecked.
+
### An offline test run takes 16 minutes instead of one (2026-10-03)
With the host's network down, `test_desktop` ran 16 minutes and the scenarios stalled past the gate's hour; online, no test takes over 4 seconds, so something waits out network timeouts.
diff --git a/docs/work/future/backlog-light.md b/docs/work/future/backlog-light.md
index bab0224cb..02597612e 100644
--- a/docs/work/future/backlog-light.md
+++ b/docs/work/future/backlog-light.md
@@ -1015,37 +1015,11 @@ away from trouble, which is what drove both driver decisions on 2026-09-09.
## HUB75, native (2026-09-16)
-The [Hub75Driver](../../../src/light/drivers/Hub75Driver.h) drives a panel from the board's own pins, as against [PanelCardDriver](../../../src/light/drivers/PanelCardDriver.h) which sends ColorLight frames to a receiving card. It ships unverified: no pixel has come out of it on real hardware, and the entries below are what a comparison against WLED-MM's path found missing. WLED-MM does not implement HUB75 itself; it wraps [mrcodetastic/ESP32-HUB75-MatrixPanel-DMA](https://github.com/mrcodetastic/ESP32-HUB75-MatrixPanel-DMA) v3.0.12 in about 600 lines (`wled00/bus_manager.cpp:677`), so the items here are measured against a library with years of hardware debugging behind it.
+The [Hub75Driver](../../../src/light/drivers/Hub75Driver.h) drives a panel from the board's own pins, as against [PanelCardDriver](../../../src/light/drivers/PanelCardDriver.h) which sends ColorLight frames to a receiving card. It has run on one panel: a 64x64 1/32-scan panel on a MoonHub75, LCD_CAM at 1,508 Hz at 6-bit, on 2026-10-06 (the bench section of the HUB75 plan). WLED-MM does not implement HUB75 itself; it wraps [mrcodetastic/ESP32-HUB75-MatrixPanel-DMA](https://github.com/mrcodetastic/ESP32-HUB75-MatrixPanel-DMA) v3.0.12 in about 600 lines (`wled00/bus_manager.cpp:677`), so the items here are measured against a library with years of hardware debugging behind it.
-### Run it on a panel, which nothing has done (the blocker for everything else)
+### The shift-register driver init
-Every other item here is a judgment made from reading. This one is the measurement. What a first session settles, roughly in the order the failures appear: whether anything lights at all; whether the bit order on the bus matches what the ribbon expects; whether the latch pulse lands correctly relative to the address change, which shows as the previous row ghosting into the current one; whether the blanking width is enough; and whether the clock phase suits the panel.
-
-The defect class this catches is exactly the one host tests cannot. F1 of the pre-merge review (the encoder truncating each slot to 8 bits, so the address, latch and output-enable lines never reached the wire) made the committed driver incapable of lighting a panel, and every host test passed throughout because the test remapped those lines into the low byte. A panel answers that question in seconds.
-
-Also unverified for the same reason: the ISR restart path and the PSRAM cache handling in [platform_esp32_hub75.cpp](../../../src/platform/esp32/platform_esp32_hub75.cpp), since the desktop build never compiles that file.
-
-### Panel quirk controls: latch blanking, clock phase, shift-register driver
-
-A HUB75 "panel" is a family, and the differences are not discoverable from its dimensions. The library exposes three knobs we have none of, each for a failure mode with a distinct look:
-
-- `latch_blanking` (1 to 3, default 2): widens the dark window around the latch. Too narrow and the previous row ghosts into the current one.
-- `clkphase`: which clock edge the panel samples on. Wrong, and the image shears by a pixel or shows the wrong column.
-- The shift-register driver: FM6124 and ICN2038S need a different init sequence. WLED-MM picks FM6124 for "outdoor" panels and its comment says the symptom of the wrong choice is a dark panel or pastel colors.
-
-Build trigger: a panel that misbehaves in one of these ways. Adding all three speculatively is three controls nobody can act on; adding the one whose symptom appears is a control with a reason.
-
-**The trigger has appeared: `latch_blanking` (2026-09-22).** A tester reports the final column dark on two panels, everything else correct. The encoder emits ONE blank word per row ([Hub75Slots.h](../../../src/light/drivers/Hub75Slots.h), the word carrying `oe | lat` after each row's data), where the reference library defaults to 2 and makes 1 to 4 configurable. A single pulse leaves the last column sitting in the shift register as LAT fires, which is the column that goes dark, and the loop already encodes every column so the data is present rather than missing. The fix is a `latchBlanking` control that repeats that blanking word, defaulting to 2 to match the prior art. A panel showing the symptom is the measurement, since the right count is visible in one flash.
-
-### Panel brightness through the output-enable window
-
-Brightness works: `Correction::apply` writes every channel through `briLut`, so the slider dims a HUB75 panel exactly as it dims a strip. The question is HOW it dims, and on this driver it costs color resolution.
-
-Scaling pixel values squeezes the available levels into the bottom of the range. At 4-bit depth that is 16 levels per channel at full brightness and roughly 8 at half, so the banding the cap already causes gets worse the further the slider comes down, which is the opposite of what a user expects from a dimmer.
-
-The library modulates the OE window per plane instead (`brtCtrlOEv2`), shortening how long each plane is lit rather than changing its value. The panel keeps its full depth at any brightness. WLED-MM therefore passes brightness straight through to `display->setBrightness(_bri)` rather than folding it into the pixel data.
-
-This shares its whole mechanism with the plane weighting below, so the two are one piece of work rather than two. Worth noting what it would cost elsewhere: `Correction` is one shared pipeline across every driver, so HUB75 taking brightness out of `briLut` means either a per-driver opt-out or a second path, and that design question is the real work rather than the OE arithmetic.
+A HUB75 "panel" is a family, and the differences are not discoverable from its dimensions. FM6124 and ICN2038S panels need a different init sequence; WLED-MM picks FM6124 for "outdoor" panels, and its comment says the symptom of the wrong choice is a dark panel or pastel colors. Build trigger: a panel showing that. The clock edge is already a control (`clockEdge`), and latch blanking was tried on the bench and changed nothing.
### Repaint only what changed
@@ -1065,9 +1039,9 @@ Some panels scan in a pattern where the physical row order does not match the lo
Our `Hub75Geometry::rowsPerScan()` handles a panel driving more than two rows per address step, which is the common multi-pair case, but not an arbitrary remap. Build trigger: someone owns such a panel. The remap belongs in the Layout rather than the driver, since it describes where lights physically are, which is what a Layout is for.
-### Bit depth is not yet weighted
+### Repeated planes: full top brightness and 8-bit depth
-`Hub75Slots.h` emits every bit plane once, so bit 7 lights for the same time as bit 0 and a value renders as a flat ramp: 0x7F and 0x80 are indistinguishable. Binary coded modulation is what the format expects, and the header documents it as the design. `bitDepth` is capped at 4 meanwhile, because an unweighted plane above that costs a scan pass and a share of the frame buffer for a difference the eye cannot find.
+The planes are weighted by their lit window (since 2026-10-06): plane p lights 2^p as long as plane 0 inside one pass, and brightness shortens every window. That costs top brightness, since the lower planes are dark for most of their pass: full white is about 47% of the row time at 4-bit and 33% at 6-bit. It also caps depth at 6, where plane 0 of a 64-column row still lights 2 words. The reference library repeats the upper planes instead, and windows only the lowest (`lsbMsbTransitionBit`, chosen automatically for a refresh floor): about 95% top brightness and 8-bit depth, at roughly 290 Hz for one 64x64 panel.
**The approach: repeat DMA descriptors, not data.** Plane `p` is stored once and the descriptor chain points at it 2^p times, so the weighting costs descriptors rather than frame memory. mrcodetastic's ESP32-HUB75-MatrixPanel-DMA does exactly this (`configureDMA`, the `for (int k = 0; k < (1 << (i - lsbMsbTransitionBit - 1)); k++)` loop), and it carries a refinement worth taking with it: `lsbMsbTransitionBit` is a dial, not a constant. Planes below it show once each; planes above get full weighting. The library raises it until a configured minimum refresh is met, trading exactness at the low end for rate. That makes depth and refresh one control instead of two fighting ones.
@@ -1075,4 +1049,5 @@ Our `Hub75Geometry::rowsPerScan()` handles a panel driving more than two rows pe
**Parlio cannot express it.** Its only repeat primitive is `loop_transmission`, which repeats the whole buffer forever with no per-plane control. Weighted depth would be an LCD_CAM capability, and the `peripheral` select has to say so rather than offering a choice that silently changes the picture.
-Worth doing after the driver has run on a real panel, not before: a flat ramp is visible in seconds on hardware, and so are the ghosting and latch-timing faults that only hardware finds. Brightness belongs in the same piece of work, since the OE window is where both live: today `Correction` scales pixel values before encoding, which spends color resolution exactly where there is least of it.
+Build trigger: a wall that reads too dim at 6-bit, or a user who needs 8-bit gradients. It stays automatic, chosen from `bitDepth` and a refresh floor, with no new setting.
+
diff --git a/docs/work/future/pins-analysis-top-down.md b/docs/work/future/pins-analysis-top-down.md
index d401ad6a3..1c4d78372 100644
--- a/docs/work/future/pins-analysis-top-down.md
+++ b/docs/work/future/pins-analysis-top-down.md
@@ -14,7 +14,7 @@ The design counterpart to [pins-analysis-bottom-up.md](pins-analysis-bottom-up.m
## 1. The goal, stated precisely
-A running MoonLight device has GPIO claims declared **all over its module tree** — `RmtLedDriver.pins`, `AudioService.sckPin/wsPin/sdPin`, `NetworkModule.ethMdcGpio/…`, `I2cScanModule.sda/scl`, `IrService.pin` — each set independently, nothing arbitrating. The goal is a single surface that answers three questions a bench operator and the firmware both need:
+A running MoonLight device has GPIO claims declared **all over its module tree** — `RmtLedDriver.pins`, `AudioService.sckPin/wsPin/sdPin`, `NetworkModule.ethMdcGpio/…`, `I2cBusModule.sda/scl`, `IrService.pin` — each set independently, nothing arbitrating. The goal is a single surface that answers three questions a bench operator and the firmware both need:
1. **Who owns GPIO N?** — the ownership map (which module, which role).
2. **Is any GPIO claimed twice?** — the conflict gate (two modules, one pin = broken output or driver-error spam).
@@ -52,7 +52,7 @@ So the module is **mostly a view + a validator over data that already exists**,
### Shape
-A **fixed System module** ([the Tasks/I2cScan pattern](system-modules.md)): a read-only `ControlType::List` via the `ListSource` adapter, refreshed on `loop1s()` (a periodic sample, never the hot path), always present, wired-by-code — same as [TasksModule](../../moonmodules/core/system.md#tasks). It reads the live tree, so it needs no state of its own.
+A **fixed System module** ([the Tasks/I2cBus pattern](system-modules.md)): a read-only `ControlType::List` via the `ListSource` adapter, refreshed on `loop1s()` (a periodic sample, never the hot path), always present, wired-by-code — same as [TasksModule](../../moonmodules/core/system.md#tasks). It reads the live tree, so it needs no state of its own.
### The enumeration
diff --git a/docs/work/future/system-modules.md b/docs/work/future/system-modules.md
index c6309b8c6..9200d03dd 100644
--- a/docs/work/future/system-modules.md
+++ b/docs/work/future/system-modules.md
@@ -20,7 +20,7 @@ The load-bearing lesson from those tools: **they sample existing OS accounting c
Every member is the **same recognizable shape** — the [TasksModule](../../moonmodules/core/system.md#tasks) template, reusing primitives that already exist:
-- a **read-only** module presenting a `ControlType::List` via the `ListSource` adapter (DevicesModule/I2cScan shape);
+- a **read-only** module presenting a `ControlType::List` via the `ListSource` adapter (DevicesModule/I2cBus shape);
- refreshed on `loop1s()` (a periodic *sample*, never the hot path);
- reading MoonLight's **existing self-report** (`loopTimeUs`/`classSize`/`dynamicBytes`) + **existing platform getters** (`freeHeap`/`freeInternalHeap`/`maxAllocBlock`/`totalHeap`, and per-module seams behind the boundary like `platform::taskSnapshot`);
- **zero hot-path cost**, cross-platform (desktop shows what it can, stubs the rest).
@@ -38,7 +38,7 @@ The System-vs-Services distinction forces a conceptual line that was already mud
The clean model, matching your framing that *"System is really to view/manage the hardware a device has"*:
-- **System** = the device's **fixed hardware + its inspection**: identity (deviceName, chip, mac), live vitals (uptime, fps, tick), reboot — **and the System Modules hang here: Tasks, Memory, Pins, I2cScan.** Always present, not user-added. These are the device's **inspection / bring-up toolkit** — Tasks (what runs), Memory (what's allocated), Pins (what's assigned), I2cScan (what's on the I²C bus). All fixed, all "inspect *this* device."
+- **System** = the device's **fixed hardware + its inspection**: identity (deviceName, chip, mac), live vitals (uptime, fps, tick), reboot — **and the System Modules hang here: Tasks, Memory, Pins, I2cBus.** Always present, not user-added. These are the device's **inspection / bring-up toolkit** — Tasks (what runs), Memory (what's allocated), Pins (what's assigned), I2cBus (what's on the I²C bus). All fixed, all "inspect *this* device."
- **Services** (new top-level container) = the **user-added capability modules**: Audio, IR — this-device bridges to the outside world. Optional, per-board.
**DevicesModule is deliberately NOT in either bucket** — it is **fleet-scope** (discovers/lists *other* devices, drives Hue, the seed of future multi-device features: groups, sync, orchestration), so it's neither a this-device System Module nor a this-device Service Module. It stays a wired-by-code child of Network for now; its eventual home is a **later decision** — a standalone top-level module, or a "Fleet"/"Devices" top-level *container* once a second fleet module exists to justify one (don't build a container for one child). Flagged here so the distinction isn't lost.
@@ -59,7 +59,7 @@ So: **Services** container, **`ModuleRole::Service`** role, and the split plan c
The split maps onto one rule, matching how OS system managers behave (Task Manager / Activity Monitor are *always available*, never added or deleted):
-- **Everything under `System` is FIXED** — always present, **no add/delete**, wired-by-code. That's System's own vitals **and** the System Modules (Tasks, I2cScan; Memory, Pins later) **and** the always-there infrastructure (Network, Firmware, Improv). You don't delete a System Module any more than you delete Task Manager.
+- **Everything under `System` is FIXED** — always present, **no add/delete**, wired-by-code. That's System's own vitals **and** the System Modules (Tasks, I2cBus; Memory, Pins later) **and** the always-there infrastructure (Network, Firmware, Improv). You don't delete a System Module any more than you delete Task Manager.
- **Everything under `Services` is USER-MANAGED** — add/delete/replace, `ModuleRole::Service`. Audio, IR. (MQTT stays code-wired under Network as always-there infra; Devices is fleet-scope, see its note.)
**`Services` is the existing container shape, applied to the core domain** — the strongest justification (*Common patterns first*). "A top-level container holding the user-added children of one role" is a pattern the codebase already runs on; `Services` reuses it rather than inventing a second arrangement. So the split adds no new concept: it recognises that Audio and IR are user-added children of one role, and that is what a container of that shape is for.
diff --git a/docs/work/present/Plan-20260912 - Documentation sweep (superseded).md b/docs/work/past/plans/Plan-20260912 - Documentation sweep (superseded).md
similarity index 93%
rename from docs/work/present/Plan-20260912 - Documentation sweep (superseded).md
rename to docs/work/past/plans/Plan-20260912 - Documentation sweep (superseded).md
index 06e52088b..e2ea42805 100644
--- a/docs/work/present/Plan-20260912 - Documentation sweep (superseded).md
+++ b/docs/work/past/plans/Plan-20260912 - Documentation sweep (superseded).md
@@ -88,7 +88,7 @@ Findings split by whether the file publishes a page, which is what decides sever
Two `@defgroup` ids are still duplicated, `moonlive_asm_riscv` and `moonlive_asm_xtensa`, each declared in a per-ISA header. Doxygen merges same-named groups, so one absorbs the other's page, which is the fault that took `drivers_PinList.md` off the site until the core parser's group was renamed. Nothing reports it yet.
-Seven areas are at **0**: `light/drivers.md`, `light/effects.md`, `light/moonlive.md`, `light/supporting.md`, `core/supporting.md`, `core/ui.md` and `platform/index.md`. The live ranking is [docgen.md](../../reference/metrics/docgen.md), which the check writes.
+Seven areas are at **0**: `light/drivers.md`, `light/effects.md`, `light/moonlive.md`, `light/supporting.md`, `core/supporting.md`, `core/ui.md` and `platform/index.md`. The live ranking is [docgen.md](../../../reference/metrics/docgen.md), which the check writes.
Red by design. The way to green is solving each file, never widening a tolerance, and once green it stays green because nothing new is exempt by construction.
@@ -104,7 +104,7 @@ A file visited twice costs twice, and the second visit re-reads everything the f
2. **Dump the comment runs once.** One pass prints every multi-line `//` and `///` run with the line it precedes. That output is the working set: do not re-read the file per finding.
3. **Settle the `///` first.** The class comment, its `@moreinfo` appendix, the `@card` line, and a one-line `///` on every public member the check names. This is the reader's view of the file, and it decides what is left to say.
4. **Then the `//`.** Each one is read against the `///` now above it: a block repeating what the doc comment says is deleted, and only a `//` carrying a constraint the `///` does not is kept and collapsed to its one line. Most are the first kind, which is why this order is cheaper than the reverse.
-5. **Apply the [Writing](../../contributing/documentation-standards.md#writing) rules while writing, not after.** Every sentence in steps 3 and 4 is written under them: present tense, positive form, factual tone, one thought per sentence, the textbook name, no self-reference, no em-dash, American spelling. Writing the sentence twice is the multi-pass this procedure exists to avoid.
+5. **Apply the [Writing](../../../contributing/documentation-standards.md#writing) rules while writing, not after.** Every sentence in steps 3 and 4 is written under them: present tense, positive form, factual tone, one thought per sentence, the textbook name, no self-reference, no em-dash, American spelling. Writing the sentence twice is the multi-pass this procedure exists to avoid.
6. **Then read the file once as a reader would.** Three rules need the whole file rather than one comment, so they cannot bind in step 5: **say each fact once** (the class comment and a member `///` six screens apart, both stating it), **the class comment leads with what the reader needs first** after the depth moved to `@moreinfo`, and **a list holds one kind of thing, most important first**. This is a read, not a rewrite: it catches what the per-comment passes structurally cannot see.
7. **Write it as one edit.** Every phase lands in a single scripted pass, with each replacement asserted unique. A second edit means the first was incomplete.
8. **Verify once.** `check_docgen` and `vale` on that file. Both silent, or go back to step 7 with what they now say.
@@ -123,7 +123,7 @@ A file visited twice costs twice, and the second visit re-reads everything the f
A header that opens with `//` generates nothing: Doxygen reads `//` as a note to the next reader of the source. Four of the six platform headers opened that way, so their pages were a bare member list with no statement of what the file is for.
-**Backlogged: a declaration-only header's functions never reach its generated page.** moxygen renders a `@defgroup`'s functions when they are `inline` definitions (`ParallelSlots.h`, `Hub75Slots.h`) and not when they are bare declarations. `platform.h` is 202 declarations against 3 definitions, so its page carries the 18 handle structs and none of `millis`, `alloc`, `gpioRead`, `ethSendRaw` or `hub75Init`: 245 functions, each with a `///` line nobody reads. The eight `Detail: technical` links on [the platform page](../../moonmodules/platform/index.md) therefore promise the interface and deliver the types it passes around, and they cannot carry per-section anchors until the sections exist. Doxygen's own XML is correct (one `func` section, 246 entries), so the fix is in the rendering: a moxygen template or upgrade, or rendering that section ourselves from the XML the generator already parses. Worth deciding alongside whether moxygen still earns its place against Doxygen's own HTML or Breathe into Sphinx, since the workarounds around it now number seven.
+**Backlogged: a declaration-only header's functions never reach its generated page.** moxygen renders a `@defgroup`'s functions when they are `inline` definitions (`ParallelSlots.h`, `Hub75Slots.h`) and not when they are bare declarations. `platform.h` is 202 declarations against 3 definitions, so its page carries the 18 handle structs and none of `millis`, `alloc`, `gpioRead`, `ethSendRaw` or `hub75Init`: 245 functions, each with a `///` line nobody reads. The eight `Detail: technical` links on [the platform page](../../../moonmodules/platform/index.md) therefore promise the interface and deliver the types it passes around, and they cannot carry per-section anchors until the sections exist. Doxygen's own XML is correct (one `func` section, 246 entries), so the fix is in the rendering: a moxygen template or upgrade, or rendering that section ourselves from the XML the generator already parses. Worth deciding alongside whether moxygen still earns its place against Doxygen's own HTML or Breathe into Sphinx, since the workarounds around it now number seven.
**Backlogged: require every `@moreinfo` section to be referenced.** `check_docgen` verifies that an `@xref{anchor}` names a heading in the same file, which catches a renamed heading silently dropping a link. The reverse rule, that every section carries at least one `@xref` pointing at it, is not enforced: 101 headers carry `@moreinfo` subsections and one uses `@xref`, so it would land roughly 300 findings on prose that is correct as written. Worth revisiting once the sweep has settled how often a reader is meant to arrive at a subsection through a link rather than by scrolling.
@@ -137,7 +137,7 @@ A header that opens with `//` generates nothing: Doxygen reads `//` as a note to
- Every em-dash, `e.g.`, British spelling and weasel Vale reports.
- No heading inside a comment, no relative `.md` link in a `///` (Doxygen drops it), no unbackticked tag.
-**And the prose itself, which no check measures.** Vale catches the mechanical half: a spelling, a dash, a weasel. The [Writing](../../contributing/documentation-standards.md#writing) section is the other half, and a header that passes every check can still fail it. What it asks for, applied to a comment:
+**And the prose itself, which no check measures.** Vale catches the mechanical half: a spelling, a dash, a weasel. The [Writing](../../../contributing/documentation-standards.md#writing) section is the other half, and a header that passes every check can still fail it. What it asks for, applied to a comment:
- **Present tense, positive form.** A comment narrating what the code no longer does is history, and git holds that. A real constraint stays; a bare absence goes.
- **A rule states a test**, and how it came to be broken belongs in the commit that fixed it. This is what most over-budget comments turn out to be: a bench story where one sentence would do.
diff --git a/docs/work/present/OPEN-WORK.md b/docs/work/present/OPEN-WORK.md
index cef134930..9ae2a5272 100644
--- a/docs/work/present/OPEN-WORK.md
+++ b/docs/work/present/OPEN-WORK.md
@@ -17,7 +17,6 @@ What is left in each plan, audited against the tree on 2026-10-02. One line per
| **Input mapping and scripted sensors** | Step 3 (analog, the expression pedal) is host-verified only; the `.mls` picker and "new script" template are unconfirmed. |
| **RTSP video out** | Verification 2 to 5 on the P4, the glass-to-glass delay against HLS above all, since no factor is claimed until it is measured. |
| **MoonLight, v5.0.0 to the rename** | Verification 1, 2, 5 and 6 (upgrade, OTA across the move, the redirect, backup to restore), and the two fidelity bench questions: the audio level scale and the reconstructed logic. |
-| **Native HUB75 output** | No pixel on real hardware yet; it waits on a community panel, and [backlog-light](../future/backlog-light.md) holds the comparison findings. |
## Larger, and its own effort
diff --git a/docs/work/present/Plan-20260915 - Native HUB75 output (shipped, unverified).md b/docs/work/present/Plan-20260915 - Native HUB75 output (shipped).md
similarity index 82%
rename from docs/work/present/Plan-20260915 - Native HUB75 output (shipped, unverified).md
rename to docs/work/present/Plan-20260915 - Native HUB75 output (shipped).md
index 9ff06e714..4009265e1 100644
--- a/docs/work/present/Plan-20260915 - Native HUB75 output (shipped, unverified).md
+++ b/docs/work/present/Plan-20260915 - Native HUB75 output (shipped).md
@@ -22,11 +22,11 @@ Measured from the encoder's own formula (`Hub75Geometry::frameBytes`, pinned by
| Panels | Geometry | 6-bit | 8-bit | Parlio (65,535 B cap) | i80/LCD_CAM (PSRAM) |
|---|---|---:|---:|:--|:--:|
-| 1 | 64×64 | 12,480 B | 16,640 B | ✅ both | ✅ |
-| 4 | 128×128 | 49,344 B | 65,792 B | ✅ 6-bit, ❌ 8-bit | ✅ |
-| 16 | 256×256 | 196,800 B | 262,400 B | ❌ | ✅ |
+| 1 | 64×64 | 12,288 B | 16,384 B | ✅ both | ✅ |
+| 4 | 128×128 | 49,152 B | 65,536 B | ✅ 6-bit, ❌ 8-bit | ✅ |
+| 16 | 256×256 | 196,608 B | 262,144 B | ❌ | ✅ |
-**Four panels at 8-bit misses the Parlio cap by 259 bytes.** That is worth stating precisely rather than as "borderline": a user with four panels gets full depth on an S3 and 6-bit on a Parlio-only chip, and the driver picks the backend that can carry what they asked for.
+**Four panels at 8-bit misses the Parlio cap by one byte.** That is worth stating precisely rather than as "borderline": a user with four panels gets full depth on an S3 and 6-bit on a Parlio-only chip, and the driver picks the backend that can carry what they asked for.
**This is the fact the issue does not account for.** #102 proposes the P4-Nano as lead candidate because Espressif's reference uses PARLIO, but [platform_esp32_parlio.cpp:238](../../../src/platform/esp32/platform_esp32_parlio.cpp) records the hardware cap: `kParlioMaxTransferBytes = 0x7FFFF / 8` = 65,535 bytes, width-invariant. PARLIO carries one panel comfortably, reaches its limit at four, and cannot do sixteen at any useful depth.
@@ -119,7 +119,7 @@ Two backends behind it, and **the user picks which**, through a `peripheral` sel
**An earlier draft of this plan had the platform choose, on memory alone. That was wrong, and the reason is contention rather than capacity.** A P4 has both peripherals and only one of each. A user driving WS2812 strips from PARLIO needs HUB75 on LCD_CAM; another user wants the reverse. Both are correct, the difference is what else is plugged into that board, and the platform cannot know it. The sibling claim guard stops two drivers colliding on one block, but it cannot guess which driver should win — that is the user's call, and a select is how the repo already asks it.
-The default is whichever backend fits the geometry, so a fresh driver works without a decision. What the plan does NOT do is silently downgrade: four panels at 8-bit is 65,792 bytes against PARLIO's 65,535-byte cap, and the driver says so rather than quietly dropping to 6-bit.
+The default is whichever backend fits the geometry, so a fresh driver works without a decision. What the plan does NOT do is silently downgrade: four panels at 8-bit is 65,536 bytes against PARLIO's 65,535-byte cap, and the driver says so rather than quietly dropping to 6-bit.
A desktop stub returning false from everything keeps `mm_tests` and every non-HUB75 target compiling untouched.
@@ -161,6 +161,20 @@ Steps 1-3 are the shippable unit. Steps 4-6 each stand alone.
What would make Phase 1 *proven* rather than shipped: a 128×128 wall at 8-bit depth, flicker-free, reported by someone who is not us. Until then the driver's docs say it is new and what is untested about it.
+### Bench, 2026-10-06: one 64×64 panel on a MoonHub75
+
+The PO's MoonHub75 (a LILYGO T7-S3 on the passive adapter) drove a 64×64 1/32-scan panel, first under WLED-MM and then under MoonLight from its catalog entry.
+
+- **The settings came from the WLED-MM install.** Its `esp32S3_16MB_PSRAM_M_HUB75` build compiles the `MOONHUB_S3_PINOUT` table, which matches the driver's MoonHub75 map pin for pin. Its HUB75 bus has `reversed` off, and WLED-MM maps that to `clkphase` false, the negative edge, so the entry sets `clockEdge` to `falling`.
+- **Measured refresh: 2,157 Hz** at 64×64, 1/32 scan, 4-bit on LCD_CAM, against the 2,441 Hz ceiling § Refresh predicts before latch overhead.
+- **A ghost the users had reported, found and fixed.** A line on row 63 also lit row 32, rows 0 and 32 read brighter on a vertical line, and the last line flashed. The LCD_CAM refill re-queues the frame after each scan, and in that gap, and before the first scan, the peripheral drives its lines low. The panel's OE is active low, so the idle bus was lit with address 0 and the last latched row. The encoder's OE bit now means lit, and the platform inverts the OE pin through the GPIO matrix after each backend claims it, so an idle bus is dark. The PO confirmed the picture clean.
+- **What did not fix it:** dark margins around each row change (latch blanking). The single 50 ns dark slot was not too short, and with the idle bus dark the panel runs clean without margins, so the control was tried and removed.
+- **Live reconfiguration:** changing an encode setting re-initialized the bus without a reboot. WLED-MM cannot change HUB75 options at runtime on an S3 and asks for one. Each control is still to try live.
+- **Step 5:** the catalog gains `LILYGO T7-S3` and `MoonHub75`. The Adafruit MatrixPortal S3 and Waveshare RGB Matrix entries remain.
+- **The last column was dark, on every effect and clock edge**, the same report a tester filed on 2026-09-22: a separate latch word was a 65th clock per 64-pixel row, so column 0 fell off and the picture moved one column left. The latch now rides the last column's word, and frames shrink by one word a row.
+- **Brightness was coarse and went black at 75 and below.** Equal-time planes gave 5 out-of-order levels, and the slider scaled values under the 4-bit floor. The planes are now weighted by their lit window and brightness is lit time, at 6-bit by default: 64 ordered levels, a smooth slider, and about a third of the former top brightness. Measured: 1,508 Hz refresh and a 2 ms longer render tick than 4-bit (~57 against ~64 fps). Repeating the upper planes for full brightness and 8-bit is backlogged.
+- **Orientation:** a sweep from row 0 down climbed the panel in the video because the panel was held upside down; the row order is correct.
+
## Risks
- **Refresh at depth may disappoint on 16 panels.** The encoder cost is linear in bit planes and the scan is serial. **Settled by publishing the prediction, not by measuring first**: § Refresh computes the ceiling per geometry and depth, the driver reports what it actually achieves, and the docs name PanelCard as the path above whatever that turns out to be. A user meets the limit with an explanation rather than a surprise.
diff --git a/docs/work/present/Plan-20261002 - Presets are state documents.md b/docs/work/present/Plan-20261002 - Presets are state documents (partial).md
similarity index 100%
rename from docs/work/present/Plan-20261002 - Presets are state documents.md
rename to docs/work/present/Plan-20261002 - Presets are state documents (partial).md
diff --git a/docs/work/present/Plan-20261003 - Code ratchets, one report for simplicity.md b/docs/work/present/Plan-20261003 - Code ratchets, one report for simplicity (partial).md
similarity index 100%
rename from docs/work/present/Plan-20261003 - Code ratchets, one report for simplicity.md
rename to docs/work/present/Plan-20261003 - Code ratchets, one report for simplicity (partial).md
diff --git a/docs/work/present/Plan-20261003 - Ethernet, WiFi and access point as Network submodules.md b/docs/work/present/Plan-20261003 - Ethernet, WiFi and access point as Network submodules (shipped, partly verified).md
similarity index 100%
rename from docs/work/present/Plan-20261003 - Ethernet, WiFi and access point as Network submodules.md
rename to docs/work/present/Plan-20261003 - Ethernet, WiFi and access point as Network submodules (shipped, partly verified).md
diff --git a/docs/work/present/Plan-20261005 - The audio codec as runtime configuration (in PR 129, M3 unverified).md b/docs/work/present/Plan-20261005 - The audio codec as runtime configuration (in PR 129, M3 unverified).md
new file mode 100644
index 000000000..d9edad6ca
--- /dev/null
+++ b/docs/work/present/Plan-20261005 - The audio codec as runtime configuration (in PR 129, M3 unverified).md
@@ -0,0 +1,37 @@
+# The audio codec as runtime configuration
+
+A board with an ES8311 codec in front of its microphone is a `deviceModels.json` entry on an existing firmware, not a firmware of its own.
+
+## Why
+
+Two community PRs add a P4 board each with an onboard ES8311: #128 (Waveshare ESP32-P4-ETH) and #55 (Guition JC-ESP32P4-M3-DEV).
+Both added a firmware variant, because the driver (`platform_esp32_es8311.cpp`) is built only for the S31 and the codec type and pins are compile-time constants in `platform_config.h`.
+The two boards share their codec wiring, so a variant per board would multiply firmwares for what is one setting.
+
+## Steps
+
+1. **Take the two codec fixes from #128**, credited to TouchMyLight: pass the I2C address pre-shifted where `esp_codec_dev` expects the 8-bit form, and set the `data_if` that `esp_codec_dev_new` requires. Both affect the S31 as well.
+2. **Build `esp_codec_dev` for the P4** as well as the S31, in `esp32/main/idf_component.yml`; measure the flash it adds on `esp32p4rev1-eth` with the flash split.
+3. **Make the codec runtime configuration**: AudioService gains `codec` (none or ES8311) and `codecAddr`, the address shown only when a codec is chosen. The bus is the board's: the I2C scan module becomes I2cBusModule, which opens it once on its pins for every device on it. None is the default, so a P4 board without one never probes the bus. `audioCodecInit` takes them from AudioService; `audioCodecType` and `audioCodecPins` leave `platform_config.h`.
+4. **The S31 CoreBoard entry** names its codec in `deviceModels.json` (SDA 51, SCL 50, MCLK 52, address 0x18), since the firmware leaves the codec to the device model.
+5. **Two catalog entries** on `esp32p4rev1-eth`, as proposed on the PRs: Waveshare ESP32-P4-ETH and Guition JC-ESP32P4-M3-DEV, both SDA 7, SCL 8, 0x18, MCLK 13, BCLK 12, WS 10 and Ethernet `P4-NANO`, with mic data on 11 (Waveshare, its wiki and a bench test) and 48 (Guition, its schematic and #55's hardware test).
+
+## Tests
+
+- AudioService, on the host: no codec is asked for until one is named with both bus pins, the request carries the bus and the 7-bit address, and every codec setting re-initializes the microphone.
+- The controls' visibility and the address shift live behind `hasI2sMic` and in the ESP32 driver, which a host test does not reach; the S31 bench run proves both.
+- `check_devices` validates the three entries.
+
+## Verification
+
+- S31 on the bench: the mic reports level and onsets through the codec, which also proves the two fixes.
+- The MM testbench P4 (direct I2S mic, no codec) keeps its audio unchanged with `codec` at none.
+- The two P4-ETH boards are verified by their PR authors on their hardware.
+
+## What comes out
+
+The compile-time codec constants, and the need for #128's and #55's firmware variants; both PRs close or rebase onto this.
+
+## Docs
+
+The AudioService card describes the codec controls; MIGRATING notes that an S31 configured before this needs its device model applied again, or the codec set by hand.
diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt
index 4ae4b8f74..16f975dfe 100644
--- a/esp32/main/CMakeLists.txt
+++ b/esp32/main/CMakeLists.txt
@@ -7,6 +7,7 @@ idf_component_register(
"../../src/module_types.cpp"
"../../src/core/module/Control.cpp"
"../../src/core/util/JsonUtil.cpp"
+ "../../src/core/util/parse.cpp"
"../../src/core/util/ScratchBuffer.cpp"
"../../src/core/system/HttpServerModule.cpp"
"../../src/core/system/FilesystemModule.cpp"
diff --git a/esp32/main/idf_component.yml b/esp32/main/idf_component.yml
index 334c744d9..37d51863b 100644
--- a/esp32/main/idf_component.yml
+++ b/esp32/main/idf_component.yml
@@ -44,18 +44,15 @@ dependencies:
- if: "$CONFIG{MM_HLS} == True"
espressif/esp-dsp:
version: "^1.5.0"
- # esp_codec_dev — Espressif's audio-codec driver library; we use its ES8311 part
- # (es8311_codec_new) to configure the ESP32-S31 CoreBoard's onboard mic, which is
- # an analog mic behind an ES8311 I2S codec (I2C addr 0x18) rather than a direct
- # I2S MEMS mic. Referenced only from platform_esp32_es8311.cpp, behind the codec
- # gate. The `rules` gate scopes it to the S31 — the only board with the codec —
- # so other targets don't resolve a component they never compile (same pattern as
- # ip101/w5500 above). A managed component outside mainline v6.0, like the P4
- # esp_hosted exception (docs/how-to/building.md § ESP-IDF version).
+ # esp_codec_dev: Espressif's audio-codec library, whose ES8311 part configures an analog mic
+ # behind an ES8311 I2S codec. Which board has one, and on which pins, is AudioService's runtime
+ # configuration from the device model, so the component is built for every chip a codec board
+ # uses (the S31 CoreBoard, and P4 boards such as the Waveshare ESP32-P4-ETH) and nowhere else.
+ # Referenced only from platform_esp32_es8311.cpp, behind the codec gate.
espressif/esp_codec_dev:
version: "^1.3.0"
rules:
- - if: "target == esp32s31"
+ - if: "target in [esp32s31, esp32p4]"
# esp_wifi_remote + esp_hosted — WiFi for the ESP32-P4 via the on-board ESP32-C6
# co-processor over SDIO. The P4 has no native radio; these present the C6's
# radio through the standard esp_wifi_* API (API-compatible, so the WiFi seam in
diff --git a/moondeck/MoonDeck.md b/moondeck/MoonDeck.md
index 84e6df4aa..748398619 100644
--- a/moondeck/MoonDeck.md
+++ b/moondeck/MoonDeck.md
@@ -580,7 +580,9 @@ findings in one file stay together and the list diffs cleanly between runs.
**Two engines, each doing what it is good at.** The compiler finds the blocking calls, because
`-Wfunction-effects` is transitive and a matcher would have to rebuild the call graph to match it.
clang-query then annotates each site with its guard — a purely local AST question — joined on
-`file:line`. Measured ~1s per TU, against a report whose cost is the clean rebuild.
+`file:line`. Measured ~1s per TU.
+
+**No clean rebuild.** The desktop build runs every compile through [`diag_launcher.py`](build/diag_launcher.py), CMake's `CXX_COMPILER_LAUNCHER` hook, which keeps each file's compiler output beside its object as `