diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c7f50542..e2c2349e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -47,9 +47,11 @@ jobs:
# them via --with is the explicit, discovery-friendly form (a bare `pytest
#
` doesn't honour a test file's own inline deps). `markdown` (a MkDocs
# dep, not in the base env) is needed by test_mkdocs_slug.py, which pins _slug()
- # against Python-Markdown's real toc slugify.
+ # against Python-Markdown's real toc slugify. `wled` is the frenck/python-wled
+ # library HA's WLED integration uses; test_wled_json_shape.py parses the
+ # device /json vector through its Device.from_dict to pin the wire contract.
- name: pytest
- run: uv run --with pytest --with pyserial --with markdown pytest test/python -q
+ run: uv run --with pytest --with pyserial --with markdown --with wled pytest test/python -q
js:
runs-on: ubuntu-latest
diff --git a/docs/adr/0012-ha-discovery-wled-default-mqtt-opt-in.md b/docs/adr/0012-ha-discovery-wled-default-mqtt-opt-in.md
new file mode 100644
index 00000000..e8780dca
--- /dev/null
+++ b/docs/adr/0012-ha-discovery-wled-default-mqtt-opt-in.md
@@ -0,0 +1,32 @@
+# 12. Home Assistant discovery: WLED by default, MQTT discovery opt-in
+
+Status: Accepted
+
+## Context
+
+A projectMM device can announce itself to Home Assistant over two independent auto-discovery mechanisms, and it implements both:
+
+- **WLED integration** — HA's built-in WLED integration discovers the device over mDNS (`_wled._tcp`) and validates it by fetching `/json`; no broker. The `HttpServerModule` WLED-compat shim serves the `/json` shape `frenck/python-wled` requires, so HA adopts the device as a light with colour, palette, and diagnostic sensors.
+- **MQTT discovery** — the `MqttModule` publishes a retained `homeassistant/light//config`, the Tasmota/ESPHome/Zigbee2MQTT convention; HA (and any Discovery-aware hub) auto-creates a wired light. This needs an MQTT broker.
+
+Both were on by default. The result on the bench: HA created a light entity from *each* path, so every device appeared **twice** — one `platform=wled` card (rich: colour, palette, sensors) and one `platform=mqtt` card (on/off + brightness only, per the discovery config). The duplication reads as a bug and confuses which card to use.
+
+The two paths are not redundant. WLED needs no broker and carries a richer entity, so it is the better default. MQTT discovery reaches where mDNS cannot — a broker-only network, or a device on a different subnet/VLAN from HA — so it earns its place as a fallback, not as a second simultaneous announcement.
+
+HomeKit does not enter the decision: neither path exposes HomeKit directly. Apple Home is reached by HA's HomeKit Bridge re-exposing whatever HA entity exists, so once the device is a light in HA (via either path) HomeKit works with no extra device-side protocol.
+
+## Decision
+
+Default to the **WLED** path; make **MQTT discovery opt-in**.
+
+The `haDiscovery` control defaults **off**. A device on defaults appears in HA exactly once, via the WLED `/json` shim over mDNS, with the full colour/palette/sensor entity and no broker required. Turning `haDiscovery` on publishes the retained MQTT discovery config for setups where mDNS does not reach HA (broker-only, cross-subnet). The two never announce the same device to the same hub by default, so HA never double-lists it.
+
+Toggling `haDiscovery` re-announces / retracts live (an empty retained config removes the MQTT entity), no reconnect.
+
+## Consequences
+
+- One HA card per device out of the box, from the richer WLED path, with zero broker setup — the common case is clean by default.
+- MQTT discovery remains available for the networks where mDNS fails; the capability isn't lost, only its default.
+- A device that wants *both* (rare) still can — turn `haDiscovery` on and accept the two cards deliberately.
+- The WLED shim becomes the load-bearing HA surface, so its `/json` correctness matters for every default install (the `seglc` capability-code and complete-segment fixes recorded in [lessons.md](../history/lessons.md) were found because of this).
+- This is [*Common patterns first*](../../CLAUDE.md#principles) applied to discovery: when a device speaks two auto-discovery protocols to one hub, exactly one is the default or the hub double-lists it.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index d2ed139a..2b5ff471 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -21,3 +21,4 @@ Agents do not read this directory automatically, only when a decision's rational
| [0009](0009-docs-generated-technical-plus-summary.md) | Two doc surfaces: generated technical + hand-written summary | Accepted |
| [0010](0010-integration-identity-stable-hardware-id.md) | Integration identity is a stable hardware id | Accepted |
| [0011](0011-data-exchange-pull-and-prepare-pass-not-pubsub.md) | Inter-module data/events: pull + prepare-pass, not pub/sub | Accepted |
+| [0012](0012-ha-discovery-wled-default-mqtt-opt-in.md) | HA discovery: WLED by default, MQTT discovery opt-in | Accepted |
diff --git a/docs/assets/deviceModels/esp32-s3-n16r8-dev.jpg b/docs/assets/deviceModels/esp32-s3-n16r8-dev.jpg
deleted file mode 100644
index 588d133d..00000000
Binary files a/docs/assets/deviceModels/esp32-s3-n16r8-dev.jpg and /dev/null differ
diff --git a/docs/assets/deviceModels/esp32-s3-n16r8-dev.png b/docs/assets/deviceModels/esp32-s3-n16r8-dev.png
new file mode 100644
index 00000000..043240ec
Binary files /dev/null and b/docs/assets/deviceModels/esp32-s3-n16r8-dev.png differ
diff --git a/docs/assets/deviceModels/esp32-s3-n8r8-dev.jpg b/docs/assets/deviceModels/esp32-s3-n8r8-dev.jpg
deleted file mode 100644
index 588d133d..00000000
Binary files a/docs/assets/deviceModels/esp32-s3-n8r8-dev.jpg and /dev/null differ
diff --git a/docs/assets/deviceModels/esp32-s3-n8r8-dev.png b/docs/assets/deviceModels/esp32-s3-n8r8-dev.png
new file mode 100644
index 00000000..043240ec
Binary files /dev/null and b/docs/assets/deviceModels/esp32-s3-n8r8-dev.png differ
diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md
index 0705877b..fed0d611 100644
--- a/docs/backlog/backlog-core.md
+++ b/docs/backlog/backlog-core.md
@@ -315,6 +315,18 @@ with the [DevicesModule command half](#devicesmodule-interop-plugins-the-command
**Open question — Homebridge example maps both `setBrightness` and `setHSV`.** In `homebridge-mqttthing`'s `lightbulb`, HSV's *value* (V) already carries HomeKit's Brightness characteristic, so mapping both topic pairs may double-drive brightness (mqttthing's docs lean toward using one or the other). The [MQTT § Homebridge example](../moonmodules/core/services.md#mqtt) currently lists both, to show the device's full topic surface. Resolve on hardware: flash a board, run Homebridge + mqttthing with that config, and check whether the Home-app brightness slider misbehaves — if it does, drop the two `brightness` topics from the example; if not, it's a non-issue. (Flagged by CodeRabbit; parked here rather than changing the doc on an untested hunch.)
+### HA update entity via MQTT discovery — release check (open follow-up)
+
+**Discovery config + install command wiring — shipped.** [`MqttModule`](../../src/core/MqttModule.cpp) now publishes a second HA-discovery component at `homeassistant/update/projectMM_/config` (same haDiscovery gate, same MAC-stable id, same device card as the light), state on `/update/state`, and subscribes to `/update/set`. HA renders a *"Firmware: "* card in the diagnostic section of the device panel. An install command routes to `platform::http_fetch_to_ota` with the release-artifact URL built from the payload version + `kFirmwareName` (`https://github.com/MoonModules/projectMM/releases/download/v/firmware--v.bin`), reusing the same OTA task the `/api/firmware/url` route drives.
+
+**Still to build — device-side release check.** `installed_version` and `latest_version` are equal today (both `MM_VERSION`), so HA renders the entity as up-to-date and the Install button is disabled. What's missing is a periodic poll of `https://api.github.com/repos/MoonModules/projectMM/releases/latest`: at boot (~30 s post-network-up) and every 24 h thereafter, fetch the release JSON, extract `tag_name`, and if newer than `MM_VERSION` call `MqttModule::publishUpdateState()` with the updated `latest_version` (the publish path is already there — only the caller is missing). ~2 KB per check.
+
+Two blockers, both platform-layer:
+- **`platform::http_fetch_to_ota` is OTA-shaped** — it writes bytes into the next OTA partition; there's no general "HTTPS GET into a buffer" seam. A small `platform::http_fetch_to_buffer(url, buf, cap, statusBuf, size_out)` next to it — ~30 lines wrapping `esp_http_client` on ESP32, a libcurl / URLSession stub on desktop — is the right home for this.
+- **JSON parsing on-device** — the existing `mm::json` scalar helpers are flat and key-order-independent; a `releases/latest` payload uses a nested `assets[]` array which the current helpers don't walk. Either extend them (~30 lines to walk one level of array), or scan for the `tag_name` string directly with `strstr` for this one caller.
+
+Once both land, add a `ReleaseCheckModule` (or a small extension inside NetworkModule / FirmwareUpdateModule — decide when building) that owns the poll timer + last-seen version cache and calls into `MqttModule::publishUpdateState()`. ~50 additional lines.
+
## Testing
### Additional test coverage (pending)
diff --git a/docs/history/lessons.md b/docs/history/lessons.md
index cf97e9c4..bc3029e5 100644
--- a/docs/history/lessons.md
+++ b/docs/history/lessons.md
@@ -243,6 +243,16 @@ HA MQTT discovery's toggle-off publishes an empty retained config (so HA removes
The catalog/summary pages hand-author each card image as a raw `
`, and the build hook moves the tag into a table cell without touching its src. MkDocs `--strict` validates markdown `![]()` links but NOT raw-HTML `
`, so 34 card images with a wrong `../` depth (the catalog pages sit one level shallower than the generated moxygen pages, so they need `../../assets`, not the moxygen pages' `../../../assets`) resolved to a nonexistent repo-root `/assets/` and 404'd on the live site while every gate passed green. The desktop build, ctest, and `--strict` all miss it; only loading the deployed page shows it. Fix + guard: a `test/python` check resolves every catalog `
` on disk (`test_mkdocs_slug.py::test_catalog_card_images_resolve_on_disk`). General: a generated-site check that only validates the syntaxes its linter knows leaves the others as silent-404 territory, verify the actual rendered output, or gate the un-validated syntax explicitly.
+## Lessons from the HA-finetuning bench pass (ha-mqtt branch, WLED `/json` + diagnostics)
+
+A live-in-Home-Assistant debugging pass over the WLED-compat surface, plus a mic bring-up that ate an afternoon. The gotchas that a green build didn't catch.
+
+- **HA reads WLED `info.leds.seglc[]` as a per-segment capability CODE, not an LED count.** The `/json` writer put the LED count in `seglc` (`seglc:[24]` on a 24-LED strip). HA's WLED integration indexes `seglc[segment_id]` and maps it through `LIGHT_CAPABILITIES_COLOR_MODE_MAPPING` (a small enum: 1 = RGB, etc.) — a `24` has no mapping, so `WLEDSegmentLight` ends up with *no supported colour modes*, HA raises `does not set supported color modes`, and the light entity stays `restored`/**unavailable** while the sensors keep working. It hid for ages because a single-light board (`count:1`) sends `seglc:[1]`, which is accidentally the valid RGB code, so those boards worked; only a *multi-LED* board tripped it. Fix: `seglc` is the constant `1`, matching `lc`; the count lives only in `count`. General shape: when impersonating another device's API, a field that *looks* numeric may be an enum/bitmask in the consumer — validate against the consumer's parser (here `frenck/python-wled` + ha-core `wled/const.py`), not against what the number "obviously" means. Pinned by `test/python/test_wled_json_shape.py` parsing a golden `/json` through the real library.
+- **HA's WLED light entity needs a *complete* segment, not a valid one.** After `seglc` was fixed the light was still stuck, because the segment carried `pal` but no `fx` — a shape real WLED never emits (it always reports both). python-wled's `Segment` half-populated, and setup still failed silently (no log line). Real WLED always pairs effect + palette; sending one without the other is the trap. Fix: emit `fx:0` alongside `pal`. When mimicking a device, match the *full* field set of the real thing for any object the consumer parses, not the subset you happen to use.
+- **A WLED-shim device shows up in HA over TWO independent discovery paths, and both fire at once.** A projectMM board announces via WLED (mDNS `_wled._tcp` + `/json`, no broker) *and*, if the `haDiscovery` control is on, via MQTT discovery (retained `homeassistant/light//config`). HA creates a light entity from *each* — so the device lists **twice** (one `platform=wled`, one `platform=mqtt`), which reads as a bug. The WLED path is richer (colour, palette, sensors) and needs no broker; MQTT is the fallback for broker-only / cross-subnet setups where mDNS can't reach. Resolution: `haDiscovery` now defaults **off** so a device appears once by default; MQTT discovery is opt-in. (Recorded as a decision in [ADR-0012](../adr/0012-ha-discovery-wled-default-mqtt-opt-in.md).) General: when a device speaks two auto-discovery protocols to the same hub, exactly one should be the default or the hub double-lists it.
+- **An Ethernet device sending a zeroed `wifi` block makes HA render greyed Wi-Fi sensors; omit the block instead.** `/json` unconditionally emitted `wifi:{bssid:00:…, rssi:0, channel:0, signal:0}` — correct-but-empty on an eth board (no AP). HA's `info.wifi` is *optional* in python-wled, so a present-but-zero block still spawns Wi-Fi RSSI/BSSID/channel sensors that sit greyed. Fix: on `ethConnected()`, drop the `wifi` object entirely — HA then creates no Wi-Fi sensors for an eth device (what a real WLED-on-eth does). General: for an optional field a consumer keys entity-creation off, *absent* and *present-but-empty* are different — absent is the honest signal for "this capability doesn't exist here."
+- **A silkscreen `45` read as `15` put an I²S mic pin on a boot strap, and the mic read dead-silent — not a bad mic.** Hours went into a "broken" mic (dead-flat RMS on every pin, phantom signal that hopped between pins run-to-run) before the cause turned out to be the WS wire on **GPIO45** (an S3 boot strap, driven at reset → silent) misread off the header as GPIO15. Two diagnostic traps compounded it: (1) `RMS>0` — or even `RMS varies` — does NOT confirm a mic, because a floating/strap/mis-numbered input pins RMS at ~255 or bounces erratically; only RMS *tracking sound* on ONE fixed pin does; (2) an earlier *actual* solder fault on the SD line masked the real issue for a while. Fixes: a mic-health status (`no samples` = clocks dead / `data line silent` = SD dead) that self-reports which wire is at fault; the working bench config is SCK=47/WS=48/SD=21 (48 is free on this rev v1.0 board, LED on 38). General: re-read physical pin numbers against the silkscreen *first* on a dead peripheral, and A/B against a known-good board on the same firmware before suspecting software.
+
## Lessons from this branch (windows-esp32-fixes)
Windows ESP32 bring-up end-to-end plus the beta1 pin-bump aftermath. Four gotchas each layered under a working build.
diff --git a/docs/moonmodules/core/services.md b/docs/moonmodules/core/services.md
index 77f8d388..3b8a88dc 100644
--- a/docs/moonmodules/core/services.md
+++ b/docs/moonmodules/core/services.md
@@ -74,7 +74,7 @@ Bridges the light to an MQTT broker so a home-automation hub (Homebridge) can co
- `broker` — the broker hostname (e.g. `homeassistant.lan`) or IP. A hostname is resolved via DNS.
- `port` — broker port (default 1883).
- `username` / `password` — broker credentials (optional; the password is stored obfuscated like the WiFi password).
-- `haDiscovery` — announce a Home Assistant MQTT-discovery light (default on). HA auto-creates a wired entity; toggling it off removes the entity. See the [home-automation guide](../../usecases/home-automation.md).
+- `haDiscovery` — announce a Home Assistant MQTT-discovery light (default off, opt-in). HA already auto-discovers the device over the WLED `/json` shim (colour + palette + sensors, no broker), so this stays off to avoid a duplicate entity; turn it on for broker-only / cross-subnet setups where mDNS doesn't reach. When on, HA auto-creates a wired entity; toggling it off removes it. See the [home-automation guide](../../usecases/home-automation.md).
- read-only — `mqtt_status` (`disabled` / `idle` / `connecting` / `connected` / `disconnected` / an error).
Detail: [technical](moxygen/MqttModule.md)
@@ -119,7 +119,7 @@ A System peripheral (added by the user, not auto-wired): an I²S microphone (or
-- `wsPin` / `sdPin` / `sckPin` — the I²S GPIOs (unset until entered).
+- `sckPin` / `wsPin` / `sdPin` — the I²S GPIOs (bit clock / word-select / data; unset until entered).
- `mclkPin` — master-clock GPIO for a line-in ADC that needs one (e.g. the PCM1808); leave unset for a plain mic.
- `sampleRate` — mic/ADC sample rate.
- `floor` / `gain` — noise floor and input gain for the analysis.
@@ -174,6 +174,8 @@ The topic prefix is `projectMM/` — a **stable** identifier (the last 6 he
| set → device | `projectMM/563cfe/hsv/set` | `h,s,v` (hue `0`–`359`, sat/val `0`–`100`) |
| device → get | `projectMM/563cfe/hsv/get` | `h,s,v` |
| device → get | `projectMM/563cfe/name` | the friendly `deviceName` (retained) |
+| device → get | `projectMM/563cfe/update/state` | `{"installed_version":…,"latest_version":…,"release_url":…,"title":…}` (retained; HA update entity) |
+| set → device | `projectMM/563cfe/update/set` | target version string (empty = install latest); triggers OTA against the matching GitHub release asset |
The HomeKit colour wheel has no "palette" concept, so `hsv/set`'s hue+saturation pick the **nearest palette** (each built-in palette has a representative colour; the closest one is selected) and the value drives brightness — the colour wheel becomes a natural palette selector.
@@ -200,7 +202,11 @@ The HomeKit colour wheel has no "palette" concept, so `hsv/set`'s hue+saturation
}
```
-Home Assistant does not need MQTT: it adopts the device through its built-in WLED integration over the existing WLED `/json` API.
+Home Assistant adopts the device two ways, both zero-config:
+- **MQTT auto-discovery** — with `haDiscovery` on (opt-in; off by default) and a broker set, the device announces itself on `homeassistant/light/projectMM_/config` and HA auto-creates a wired entity with **on/off + brightness** (the config declares `brightness` only; colour isn't in it, so the entity has no colour control). Retained across reboots. Colour/palette stays on the separate `hsv/set` topic above, not this entity. Off by default because the WLED `/json` shim already gives HA a richer light (colour + palette + sensors) over mDNS with no broker — leaving both on lists the device twice; enable this only for broker-only / cross-subnet setups.
+- **WLED integration** — HA's built-in WLED integration discovers the device over the WLED `/json` API projectMM already serves; on/off + brightness work with no broker.
+
+Both can be on at once. Setup walkthrough (including exposing HA to Apple Home via HA's HomeKit Bridge, no Homebridge needed) in the [Home Assistant recipe](../../usecases/home-automation.md#adopt-in-home-assistant).
## File Manager — details
diff --git a/docs/reference/gpio-usage.md b/docs/reference/gpio-usage.md
new file mode 100644
index 00000000..64b7f351
--- /dev/null
+++ b/docs/reference/gpio-usage.md
@@ -0,0 +1,67 @@
+# GPIO usage per MCU — hardware reference
+
+Which GPIOs are safe to wire a mic, an LED strand, or an Ethernet PHY to on each MCU projectMM supports, so a bench build picks a pin from here instead of re-scraping the datasheet. This is **chip**-level (which GPIOs the silicon reserves or role-assigns); a specific product's *board* wiring is its catalog entry in [`web-installer/deviceModels.json`](../../web-installer/deviceModels.json), and the one fully-mapped board is the [ESP32-S31 coreboard](esp32-s31-coreboard.md).
+
+Each chip lists two kinds of pin to avoid:
+
+- **Reserved** — wired to flash / PSRAM / native USB on-silicon. Using one crashes or corrupts the device. **Never** route peripheral I/O here.
+- **Role-conflict** — a usable GPIO that also carries a default role (JTAG, the UART0 console, a boot strap). Usable for I/O, but you give up that role: no hardware JTAG debug, no serial console, or a boot-mode hazard if the pin is driven during reset. Fine for a peripheral you're willing to trade the role for.
+
+Everything else is free for user I/O. Since the ESP32 I²S / RMT / LED peripherals route through the GPIO matrix, any free GPIO works for a mic or an LED lane; there is no fixed pinout to match.
+
+**Sources:** the per-chip Espressif datasheet (linked in each section). When picking defaults for a new chip, read its strapping-pin and flash/PSRAM tables from the datasheet first, not from a tutorial.
+
+## ESP32 (classic)
+
+[Datasheet](https://www.espressif.com/sites/default/files/documentation/esp32_datasheet_en.pdf). 34 GPIOs, but several are input-only or strapping.
+
+| Avoid | GPIOs | Why |
+|-------|-------|-----|
+| Reserved | **6-11** | SPI flash (the on-package flash bus). Always off-limits. |
+| Reserved | **16, 17** | Extra flash/PSRAM bus on WROVER (PSRAM) modules; free on plain WROOM. |
+| Role-conflict | **0, 2, 5, 12, 15** | Boot straps. Usable, but a wrong level at reset changes boot mode (GPIO 12 = flash voltage is the dangerous one). Don't drive during boot. |
+| Input-only | **34, 35, 36, 39** | No output driver and no internal pull-ups. Fine for a mic **SD/SCK/WS input**, useless for an LED output. |
+
+**Clear for output I/O:** 4, 13, 14, 18, 19, 21-23, 25-27, 32, 33 (plus 16/17 on non-PSRAM). Input-only 34-39 suit mic data lines.
+
+## ESP32-S3
+
+[Datasheet](https://www.espressif.com/sites/default/files/documentation/esp32-s3_datasheet_en.pdf). 45 GPIOs. The reserved set **depends on the module's PSRAM**, which is the trap.
+
+| Avoid | GPIOs | Why |
+|-------|-------|-----|
+| Reserved | **26-32** | SPI flash bus. Always off-limits. |
+| Reserved (**octal-PSRAM only, the N16R8 / R8 modules**) | **33-37** | Octal PSRAM's SPIIO4-7 + DQS. On an R8 module these are **not** GPIOs; using one corrupts PSRAM. On quad-PSRAM (N8R2) or no-PSRAM parts they are free. Check the module suffix. |
+| Role-conflict | **39, 40, 41, 42** | JTAG (MTCK/MTDO/MTDI/MTMS). Usable as GPIO, but you lose hardware JTAG debug. Keep free if you plan to add JTAG. |
+| Role-conflict | **19, 20** | Native USB D-/D+ (the USB-C port on DevKitC boards). Usable, but breaks the native-USB console/DFU. |
+| Role-conflict | **43, 44** | UART0 TX/RX — the default serial console. Take them and you lose `printf`-over-serial. |
+| Role-conflict | **0, 45, 46** | Boot straps. Don't drive at reset. |
+
+**Clear for I/O on the N16R8:** 1-18, 21, 47, 48 (mind GPIO 3 is a soft strap; the on-board WS2812 LED sits on **48** on DevKitC-1 rev v1.1 but on **38** on rev v1.0, so avoid whichever your board uses — the bench board is a rev v1.0, LED on 38, leaving 48 free). Example, the bench mic on the N16R8 is **SCK=47 / WS=48 / SD=21** — clear of octal PSRAM, JTAG (39-42 stay free for a future JTAG probe), the boot straps, and (on this rev) the LED, with the mic wires kept away from the Wi-Fi antenna end.
+## ESP32-P4
+
+[Datasheet](https://www.espressif.com/sites/default/files/documentation/esp32-p4_datasheet_en.pdf). 55 GPIOs, RISC-V. The P4 has **no native radio**; WiFi rides an on-board ESP32-C6 over SDIO, so several pins carry that link.
+
+| Avoid | GPIOs | Why |
+|-------|-------|-----|
+| Reserved | **flash/PSRAM pins per module** | The HP SPI flash + PSRAM bus (module-specific; the Waveshare P4-NANO wires them internally). Off-limits. |
+| Role-conflict | **34-38** | Strapping pins (boot mode). Don't drive at reset; the first LED-driver default wrongly landed here (see [lessons.md](../history/lessons.md)). |
+| Role-conflict | **37, 38** | UART0 console on the P4-NANO (`CONFIG_ESP_CONSOLE_UART_DEFAULT`) — the runtime `ESP_LOGI` lines come out here, not over USB. |
+| Board-wired (P4-NANO) | **Ethernet RMII** 28-31 / 49-52, **C6 SDIO** 14-19 / 54, **I2C** 7-8 | Consumed by the NANO's on-board Ethernet PHY, the C6 WiFi co-processor, and the I2C bus. |
+
+**Clear on the P4-NANO:** 20-27, 32-33, 39-48 (the LED-driver default is `pins="20,21,22,23,24,25,26,27"`). Exact free set is board-specific; the NANO's is the reference.
+
+## ESP32-S31
+
+[Datasheet](https://documentation.espressif.com/esp32-s31_datasheet_en.pdf). RISC-V, Wi-Fi 6 + on-chip 1 Gbps EMAC; shares the MoonLive RISC-V backend with the P4. The bench board's full schematic pin map (Ethernet RGMII, ES8311 audio codec, SD, USB-host) is on its own page: [ESP32-S31 coreboard](esp32-s31-coreboard.md).
+
+| Avoid | GPIOs | Why |
+|-------|-------|-----|
+| Reserved | flash/PSRAM per module | On-package flash + PSRAM bus. Off-limits. |
+| Board-wired (coreboard) | **RGMII Ethernet** + **ES8311 I2S/I2C** + **SD** + **USB-host** | The Function-CoreBoard-1 uses a large set for its on-board peripherals — see the [coreboard reference](esp32-s31-coreboard.md) for the exact map, since the audio codec (not a bare I²S mic) drives its own pins. |
+
+For the S31 coreboard, take pins from the coreboard reference's "free" set rather than guessing, because so much of the header is committed to on-board peripherals.
+
+## The rule behind the defaults
+
+projectMM leaves peripheral pins **unset** by default and lets the deviceModel catalog fix them only where the *product* wires them (the [Defaults rule](../coding-standards.md#defaults)): a board-soldered PHY or codec defaults its pins; a user-soldered mic or LED strand stays unset so a guess can't drive a pin the user committed elsewhere. This table is what to consult when choosing that user pin, or when writing a new board's catalog entry.
diff --git a/docs/usecases/home-automation.md b/docs/usecases/home-automation.md
index 5d1a112b..03825342 100644
--- a/docs/usecases/home-automation.md
+++ b/docs/usecases/home-automation.md
@@ -4,35 +4,184 @@ Bring a projectMM device into your smart home — controlled alongside your ligh
Integration goes **both directions**, and this page covers both:
-- **Your smart home controlling the device** — a hub (Homebridge, Home Assistant, …) adopts the projectMM device and drives its on/off, brightness, and colour from its own app, voice assistant, and automations.
+- **Your smart home controlling the device** — a hub (Home Assistant, Homebridge, …) adopts the projectMM device and drives its on/off, brightness, and colour from its own app, voice assistant, and automations.
- **The device controlling smart-home lights** — projectMM drives **Philips Hue** bulbs as effect pixels, so your existing smart bulbs become part of a show.
-The recipes below are each self-contained:
+The device-side recipes below assume the hub and (where relevant) an MQTT broker are already running. If any of that infrastructure isn't there yet, jump to [Set up the infrastructure](#set-up-the-infrastructure) at the end for HA-with-Mosquitto and standalone-Homebridge walkthroughs, then come back.
-- **[Homebridge (Apple Home / HomeKit)](#homebridge-apple-home-homekit)** — bridge the device over MQTT so it appears as a HomeKit accessory. The full walkthrough.
+- **[Adopt in Home Assistant](#adopt-in-home-assistant)** — point the device at HA's broker; HA auto-creates the entity. Optionally bridge that entity into Apple Home via HA's HomeKit Bridge.
+- **[Adopt in Homebridge](#adopt-in-homebridge)** — standalone Apple-Home-only path when HA isn't wanted: add one `mqttthing` accessory, pair in Home.
- **[Drive Hue lights](#drive-hue-lights)** — point an effect at your Hue bulbs.
-- **[Other platforms](#other-platforms)** — how Home Assistant and other MQTT hubs reach the same device.
+- **[Other platforms](#other-platforms)** — Node-RED, openHAB, and other MQTT hubs reach the device through the same topics.
The control surface these integrations drive — the MQTT controls, topics, and accessory config — is documented once in [Core › Services › MQTT](../moonmodules/core/services.md#mqtt); this page is the setup, and links there for the reference.
## Prerequisites
-- A projectMM device on your WiFi/Ethernet with the [MQTT module](../moonmodules/core/services.md#mqtt) present (it ships on boards whose catalog entry includes it; the Shelly model does).
-- The device's **MAC suffix** — the last 6 hex of its MAC, which is its stable topic id (`projectMM/`). Read it from the MQTT module's `mqtt_status`, or once the device is talking to the broker, `mosquitto_sub -t 'projectMM/#'`. The examples below use `563cfe`; substitute yours.
+- A projectMM device on your WiFi/Ethernet. For the MQTT-based paths (HA MQTT-discovery, Homebridge) the [MQTT module](../moonmodules/core/services.md#mqtt) must be present (it ships on boards whose catalog entry includes it; the Shelly model does); for the WLED-only HA path it isn't needed — HA reaches the device over its always-present `/json` endpoint.
+- The device's **MAC suffix** — the last 6 hex of its MAC, which is its stable topic id on MQTT (`projectMM/`) and identifies the device to HA on the WLED path too. Read it from the MQTT module's `mqtt_status`, or once the device is talking to the broker, `mosquitto_sub -t 'projectMM/#'`. The examples below use `563cfe`; substitute yours.
+- A running hub — with per-path infra:
+ - **HA + Mosquitto broker add-on** — for MQTT auto-discovery (richer state + HomeKit Bridge). [Set up HA + Mosquitto](#set-up-home-assistant-mosquitto).
+ - **HA alone (no broker)** — for the WLED-integration path. Same HA install, just skip the Mosquitto add-on.
+ - **Homebridge + a Mosquitto broker** — for the standalone Apple-Home-only path. [Set up standalone Homebridge + Mosquitto](#set-up-standalone-homebridge-mosquitto).
-## Homebridge (Apple Home / HomeKit)
+## Adopt in Home Assistant
-[Homebridge](https://homebridge.io) exposes non-HomeKit devices to Apple Home. The chain is the same however you host it: an **MQTT broker** sits in the middle, because Homebridge's `homebridge-mqttthing` plugin is an MQTT *client*, not a broker. The device publishes/subscribes to the broker; Homebridge talks to the same broker; HomeKit talks to Homebridge.
+Assuming HA is running with the Mosquitto broker add-on installed (if not, [Set up the infrastructure](#set-up-home-assistant-mosquitto) below), two steps: **point the device at the broker**, then **let HA auto-create the entity**. A third optional step brings it into Apple Home via HA's own HomeKit Bridge — no Homebridge needed.
+
+The chain:
```
-Apple Home ──HAP──▶ Homebridge ──MQTT──▶ broker ◀──MQTT── projectMM device
+Apple Home ──HAP──▶ HA (HomeKit Bridge) ──MQTT──▶ Mosquitto ◀──MQTT── projectMM device
+ └── HA app / voice / automations
```
-So there are two things to do: **install** a broker + Homebridge (the only part that differs by host — a Pi for a permanent setup, a Mac/PC for a quick test), then **configure** the device and the accessory (identical everywhere). If you already run Homebridge and a broker, skip straight to [Set up Homebridge](#set-up-homebridge).
+### 1. Point the device at the broker
+
+In the device's web UI, open the **MQTT** module and set:
+
+- `broker` — the **HA VM's LAN IP** (not `127.0.0.1`; not `homeassistant.local` unless the device does mDNS — safer to use the IP the HAOS console prints).
+- `port` — `1883`.
+- `username` / `password` — the HA user you created for MQTT (a plain HA user with local-network access; see the [broker add-on setup](#install-the-mosquitto-broker-add-on) if you haven't).
+- `haDiscovery` — leave **on** (the default). This is what makes HA auto-create the entity in the next step.
+
+`mqtt_status` turns to `connected` when it reaches the broker. Confirm from HA with the built-in **MQTT integration → Configure → Listen to topic → `projectMM/#`** — the device's `.../on/get`, `.../brightness/get`, `.../hsv/get`, and retained `.../name` appear.
+
+### 2. Let HA auto-create the entity
+
+Nothing to configure — HA discovers the device automatically over the WLED path. Two paths exist; the WLED one is the default and the richer of the two, so a device appears in HA **once** out of the box:
+
+- **WLED integration** (the default, no MQTT at all): HA's built-in WLED integration discovers the device over zeroconf and reads the WLED-compatible `/json` API projectMM already serves — **colour, palette, brightness, and diagnostic sensors**, no broker. This is the recommended path.
+- **MQTT auto-discovery** (opt-in, uses the Mosquitto add-on): turn `haDiscovery` on for a broker-only or cross-subnet setup where zeroconf can't reach HA. The device then also announces itself on the retained `homeassistant/light/…/config` topic, and HA **auto-creates a light entity** named after the device with **on/off + brightness** (the discovery config declares `brightness` only, so this entity has no colour control); it greys out when the device drops offline. Colour/palette control stays on the separate `hsv/set` topic — not the auto-created entity.
+
+**Why is MQTT discovery off by default?** Because the WLED path alone already gives HA a richer light — colour, palette, sensors — with zero infrastructure, and running both lists the device **twice** (one WLED entity, one MQTT entity). So WLED is the default and MQTT discovery is opt-in, reserved for what WLED's zeroconf can't do: reach across VLANs/guest nets or a broker-only network, sit next to other MQTT devices (Tasmota, ESPHome, Zigbee2MQTT) under one convention, or push state with retained-across-offline latency. On a flat LAN with only projectMM devices, WLED alone is the whole story; a segmented network or an existing MQTT estate is where turning `haDiscovery` on earns its slot. The full rationale is [ADR-0012](../adr/0012-ha-discovery-wled-default-mqtt-opt-in.md).
+
+The MQTT control surface (topics, HSV → palette mapping, retained state) is in [Core › Services › MQTT](../moonmodules/core/services.md#mqtt); nothing here restates it.
+
+**Not this:** do **not** hand-configure HA's generic *MQTT light* against `homeassistant/light//…` — that's HA's own discovery topic schema, not a control API. The device answers via the auto-discovery above.
+
+### 3. Optional: expose to Apple Home via HomeKit Bridge
+
+HA can act as its own HomeKit bridge. **Settings → Devices & Services → Add Integration → HomeKit Bridge**, pick **Include selected entities**, and tick the projectMM light entity created above. HA prints a QR code and PIN; in the iOS **Home** app, **Add Accessory → More options → HA HomeKit Bridge** and scan / enter the PIN. The light shows up as a HomeKit accessory driven end-to-end through HA — same outcome as the standalone Homebridge recipe, one fewer service to run.
+
+## Adopt in Homebridge
+
+Standalone Apple-Home-only path, for when HA isn't wanted. Assumes Homebridge + a Mosquitto broker are already running (both together on a Pi is the usual permanent setup; a Mac or PC works for a quick test — see [Set up standalone Homebridge + Mosquitto](#set-up-standalone-homebridge-mosquitto) below if not). Two steps: **point the device at the broker**, then **add one accessory to Homebridge**.
+
+```
+Apple Home ──HAP──▶ Homebridge ──MQTT──▶ Mosquitto ◀──MQTT── projectMM device
+```
+
+### 1. Point the device at the broker
+
+In the device's web UI, open the **MQTT** module and set:
+
+- `broker` — the broker's hostname or **LAN** IP (not `127.0.0.1`, which is loopback on the *broker's* host, unreachable from the device). Find it with `hostname -I` (Pi/Linux), `ipconfig getifaddr en0` (macOS), or `ipconfig` (Windows).
+- `port` — usually `1883`.
+- `username` / `password` — only if your broker requires auth.
+
+`mqtt_status` turns to `connected` once it reaches the broker, and `mosquitto_sub -t 'projectMM/#' -v` shows the device's `.../on/get`, `.../brightness/get`, `.../hsv/get`, and retained `.../name`.
+
+### 2. Add the accessory to Homebridge
+
+Install the [`homebridge-mqttthing`](https://github.com/arachnetech/homebridge-mqttthing) plugin (Config UI **Plugins** screen, or `npm i -g homebridge-mqttthing`), then add the `lightbulb` accessory block from [Core › Services › MQTT § Homebridge](../moonmodules/core/services.md#mqtt) to your Homebridge config — via the Config UI **Config** editor, or `~/.homebridge/config.json` for a CLI install. Set:
+
+- `url` — `mqtt://:1883`. When Homebridge and the broker share a host (the usual case), `mqtt://127.0.0.1:1883` works, since here loopback *is* the broker.
+- `563cfe` — replaced by your device's MAC suffix throughout the `topics`.
+- `username` / `password` — matching the broker, or removed if none.
+
+Restart Homebridge.
+
+### 3. Pair it in Apple Home
+
+Once the broker, device, and Homebridge are all talking (the `mosquitto_sub` window shows the device, and Homebridge lists the accessory), add it to HomeKit: in the **Home** app, **Add Accessory → More options →** the Homebridge bridge, using the PIN Homebridge prints on start (or shown in the Config UI). The device shows up as a light — on/off, brightness, and the colour wheel that steps through palettes.
+
+## Drive Hue lights
+
+The other direction: instead of a hub controlling the device, the **device controls your Philips Hue bulbs**, treating each colour bulb as a pixel of an effect. Your existing smart lights join the show — an effect's colours glide across them alongside (or instead of) an LED strip. This is a projectMM **output driver**, not a hub integration, so there's no broker and no Homebridge — the device talks straight to the Hue bridge over its LAN HTTP API.
+
+Because Hue is a rate-limited HTTP hub (~10 commands/s), this is **smooth ambient colour**, not fast strobing — the driver paces itself to the bridge and lets it fade between colours. Full behaviour, controls, and the wire contract are in the driver reference: [Drivers › Hue](../moonmodules/light/drivers.md#hue).
+
+**Recommended layout:** set up a **one-dimensional grid — width 1, height = the number of Hue lights** you want to control. Each pixel of that column maps to one bulb (the driver assigns window pixels to bulbs in order), so a 1×N grid gives you exactly N discrete lights with no wasted pixels, and 1D effects (rainbow, chase, …) read naturally across the bulbs. Sizing the grid to your bulb count keeps the effect and the driver in step.
+
+To set it up:
+
+1. **Add a Hue driver.** In the device's web UI pipeline (**Layers → a Layer → its Drivers**), add a **Hue** driver. Enter your bridge's IP in `bridgeIp` (find it in the Hue app, or at [discovery.meethue.com](https://discovery.meethue.com)).
+2. **Pair with the bridge.** Press the physical **link button** on the Hue bridge, then click the driver's **`pair`** button within ~30 seconds. The device claims an app key (stored on the driver as `appKey`) — a one-time step; the status line reports `paired, N lights`.
+3. **Pick what it drives.** The driver lists the bridge's colour-capable, reachable bulbs and its rooms; use the `room` / `light` controls to aim the effect at all bulbs, one room, or a single light. Each selected bulb becomes one pixel of the driver's window.
+4. **Run an effect.** Any effect on the layer now drives the bulbs — the global brightness slider and colour-order correction apply to them just like a physical strip (brightness 0 turns a bulb off).
+
+*Note:* a bulb is only driven if it's an "Extended color light" and currently reachable — a white-only bulb, a plug, or a powered-off light is skipped. For true real-time (fast) Hue, the [Hue Entertainment API](https://developers.meethue.com/develop/hue-entertainment/) (DTLS streaming) is a separate future path; today's driver targets the standard API's ambient-colour sweet spot.
+
+## Other platforms
+
+Home Assistant and Homebridge above are the worked examples; other home-automation platforms adopt the same device through the same primitives. The building blocks are shared — the [MQTT control surface](../moonmodules/core/services.md#mqtt) (broker + the `on`/`brightness`/`hsv` topics) and, for hubs that speak it, the device's WLED compatibility — so a new integration is a new front-end on top, not new device work.
+
+**Node-RED, openHAB, voice assistants via a hub, …** — anything that speaks MQTT drives the device through the [control topics](../moonmodules/core/services.md#mqtt); the broker setup is the same [Mosquitto install](#set-up-standalone-homebridge-mosquitto) at the end of this page, only the consumer differs.
+
+Each integration is a self-contained recipe; the shared MQTT reference lives once in [Core › Services › MQTT](../moonmodules/core/services.md#mqtt).
+
+## Set up the infrastructure
+
+The two recipes above assume the hub + broker are running. Here's how to install them if not — one section for **Home Assistant + Mosquitto** (the recommended path — richer control surface, HomeKit bridge included) and one for **standalone Homebridge + Mosquitto** (Apple-Home-only, no HA).
+
+### Set up Home Assistant + Mosquitto
+
+Two pieces: **Home Assistant Operating System (HAOS)** in a VM, then the **Mosquitto broker add-on** installed from HA's Add-on Store.
+
+**Wired Ethernet on the host is strongly recommended.** WiFi-bridged VMs work in theory but tend to disconnect for a few seconds every few minutes when the bridge re-associates; the device on the LAN sees the broker vanish and reconnect on the same cadence, which surfaces as entities flapping to "unavailable" in HA. A wired external switch on Hyper-V (or ProxMox / ESXi) is stable indefinitely.
+
+#### Install HAOS
+
+The recommended install is **HAOS in a VM** — it's the only image that supports the *Add-on Store* (Mosquitto and its friends install with one click there). Container / core installs work, but every add-on becomes a separate `docker run`. Below is the Hyper-V route on Windows; on other hosts follow the equivalent official page (any of ProxMox, ESXi, KVM, VirtualBox, or a Raspberry Pi 4/5 for a dedicated box — [installation.home-assistant.io](https://www.home-assistant.io/installation/) has the current image for each).
+
+**Windows: Hyper-V**
+
+Windows 10/11 **Pro** or **Enterprise** ships Hyper-V; the Home editions don't. Confirm with `systeminfo | findstr /I "Hyper-V"` — Hyper-V lines that end in `Yes` mean the CPU supports it and BIOS virtualization is on.
+
+1. **Enable the feature.** Open **Turn Windows features on or off** and tick **Hyper-V** (both sub-items). Reboot when it prompts.
+2. **Download the HAOS Hyper-V image.** [Home Assistant OS releases](https://github.com/home-assistant/operating-system/releases) — grab the `haos_ova-*.vhdx.zip` for Hyper-V and unzip it somewhere you'll keep the VM disk (e.g. `C:\Hyper-V\HAOS\`).
+3. **Create an external virtual switch on your Ethernet.** In **Hyper-V Manager → Virtual Switch Manager**, add a **New virtual switch → External**, bind it to the Ethernet adapter, and untick *Allow management operating system to share*. This puts the VM directly on the LAN with its own DHCP lease — HA and the device see each other with no NAT.
+4. **Create the VM.** **Hyper-V Manager → New → Virtual Machine**. Name it `HomeAssistant`; **Generation 2**; assign at least **2 GB RAM** (2 GB is fine for a home LAN, 4 GB is comfortable); attach the network to the external switch you just made; **Use an existing virtual hard disk** and point at the unzipped `.vhdx`; finish.
+5. **Disable Secure Boot.** Right-click the new VM → **Settings → Security** → untick **Enable Secure Boot** (HAOS's shim isn't in the Microsoft template).
+6. **Start the VM.** It boots to a text console — wait ~2 minutes for the first-run "Preparing Home Assistant" to finish. When the console shows `homeassistant login:`, open a browser on the host at **[http://homeassistant.local:8123](http://homeassistant.local:8123)** (or the LAN IP the console prints) and complete the onboarding wizard.
+7. **Keep the host awake and the VM auto-managed.** An always-on HA host must not sleep — an S3 or hibernate suspend pauses Hyper-V, and every projectMM device / HA entity flips to "unavailable" until it wakes. In an admin PowerShell:
+
+ ```powershell
+ # what sleep states this host actually supports (tells you what to disable)
+ powercfg /a
+ # never sleep on AC (the common case — S3 is what most desktops/laptops have)
+ powercfg /change standby-timeout-ac 0
+ # hibernate off — also disables Hybrid Sleep + Fast Startup, which chain off it
+ powercfg /hibernate off
+ ```
+
+ Modern-standby (`S0 Low Power Idle`) hosts still respect `standby-timeout-ac 0`, so the same command covers both. If it's a laptop, close-lid also has to stop sending the sleep signal:
+
+ ```powershell
+ powercfg /setacvalueindex SCHEME_CURRENT SUB_BUTTONS LIDACTION 0
+ powercfg /setactive SCHEME_CURRENT
+ ```
-### Install a broker + Homebridge
+ Then in **Hyper-V Manager → HomeAssistant → Settings → Management**:
+ - **Automatic Start Action → Always start this virtual machine automatically** (Startup delay 60s so the vSwitch is up first). Windows reboots → HA is back without touching Hyper-V Manager.
+ - **Automatic Stop Action → Save the virtual machine state**. A planned shutdown suspends HA cleanly and resumes it on next boot, avoiding a cold-start with the SQLite in an uncertain state.
-Only this part is host-specific — pick your platform. Each installs **Mosquitto** (the broker) and **Homebridge**; the next section wires them up the same way regardless. A **Raspberry Pi** is the natural permanent home (services survive a reboot); a **Mac or PC** is fine for a throwaway test. Already have both running? Jump to [Set up Homebridge](#set-up-homebridge).
+8. **Optional cleanup:** if VirtualBox was running a previous HA VM, disable its `VBoxSVC` autostart from **Task Manager → Startup**. VirtualBox's WiFi-bridge driver is the flapping cause noted above, so leaving it off keeps latency low.
+
+#### Install the Mosquitto broker add-on
+
+In HA, **Settings → Add-ons → Add-on Store → Mosquitto broker → Install**, then **Start**. Turn on **Start on boot** and **Watchdog**.
+
+The add-on runs an MQTT 3.1.1 broker on the HA VM at **`:1883`**. It uses **HA users as MQTT credentials**: create a dedicated user for the device (**Settings → People → Users → Add user**; name it e.g. `mqttuser`, tick *Can only log in from the local network*, set a password), and MQTT clients then authenticate with that user's credentials. No `mosquitto.conf` editing.
+
+The Mosquitto add-on and the MQTT integration are separate: the add-on runs the broker; the integration is HA's *client* to it. HA usually auto-adds the MQTT integration on first add-on start; if not, **Settings → Devices & Services → Add Integration → MQTT**, host `core-mosquitto`, port `1883`, use the user credentials.
+
+Now jump back to [Adopt in Home Assistant](#adopt-in-home-assistant).
+
+### Set up standalone Homebridge + Mosquitto
+
+For an Apple-Home-only setup without HA. A **Raspberry Pi** is the natural permanent home (services survive a reboot); a **Mac or PC** is fine for a throwaway test.
Wherever the broker runs, the device reaches it over the LAN, so the broker must listen on all interfaces, not just loopback — noted per platform below. Confirm the broker sees the device at any point with:
@@ -96,59 +245,20 @@ Install Homebridge for Windows ([the official installer](https://github.com/home
**Tearing it down:** Ctrl-C the foreground `mosquitto` (leave the broker service stopped, or disable it in **Services**); stop the Homebridge service from the Config UI or **Services**.
-### Set up Homebridge
-
-With a broker and Homebridge running — anywhere — the rest is host-independent: point the device at the broker, then add one accessory to Homebridge. Do this whether you just installed both above or already had them.
-
-1. **Point the device at the broker.** In the device's web UI, open the **MQTT** module and set:
- - `broker` — the broker's hostname or IP. Use the broker host's **LAN** IP (or `.local` name) so the device can route to it — not `127.0.0.1`, which is loopback on the *host*, unreachable from the device. Find it with `hostname -I` (Pi/Linux), `ipconfig getifaddr en0` (macOS), or `ipconfig` (Windows).
- - `port` — usually `1883`.
- - `username` / `password` — only if your broker requires auth (the test brokers above don't).
-
- `mqtt_status` turns to `connected` once it reaches the broker, and `mosquitto_sub -t 'projectMM/#' -v` shows the device's `.../on/get`, `.../brightness/get`, `.../hsv/get`, and retained `.../name`.
-
-2. **Add the accessory to Homebridge.** Install the [`homebridge-mqttthing`](https://github.com/arachnetech/homebridge-mqttthing) plugin (Config UI **Plugins** screen, or `npm i -g homebridge-mqttthing`), then add the `lightbulb` accessory block from [Core › Services › MQTT § Homebridge](../moonmodules/core/services.md#mqtt) to your Homebridge config — via the Config UI **Config** editor, or `~/.homebridge/config.json` for a CLI install. Set:
- - `url` — `mqtt://:1883`. When Homebridge and the broker share a host (the usual case), `mqtt://127.0.0.1:1883` works, since here loopback *is* the broker.
- - `563cfe` — replaced by your device's MAC suffix throughout the `topics`.
- - `username` / `password` — matching the broker, or removed if none.
-
- Restart Homebridge.
+Now jump back to [Adopt in Homebridge](#adopt-in-homebridge).
-### Pair it in Apple Home
-
-Once the broker, device, and Homebridge are all talking (the `mosquitto_sub` window shows the device, and Homebridge lists the accessory), add it to HomeKit: in the **Home** app, **Add Accessory → More options →** the Homebridge bridge, using the PIN Homebridge prints on start (or shown in the Config UI). The device shows up as a light — on/off, brightness, and the colour wheel that steps through palettes — the same control surface a real hub gives, verified locally.
-
-## Drive Hue lights
-
-The other direction: instead of a hub controlling the device, the **device controls your Philips Hue bulbs**, treating each colour bulb as a pixel of an effect. Your existing smart lights join the show — an effect's colours glide across them alongside (or instead of) an LED strip. This is a projectMM **output driver**, not a hub integration, so there's no broker and no Homebridge — the device talks straight to the Hue bridge over its LAN HTTP API.
-
-Because Hue is a rate-limited HTTP hub (~10 commands/s), this is **smooth ambient colour**, not fast strobing — the driver paces itself to the bridge and lets it fade between colours. Full behaviour, controls, and the wire contract are in the driver reference: [Drivers › Hue](../moonmodules/light/drivers.md#hue).
-
-**Recommended layout:** set up a **one-dimensional grid — width 1, height = the number of Hue lights** you want to control. Each pixel of that column maps to one bulb (the driver assigns window pixels to bulbs in order), so a 1×N grid gives you exactly N discrete lights with no wasted pixels, and 1D effects (rainbow, chase, …) read naturally across the bulbs. Sizing the grid to your bulb count keeps the effect and the driver in step.
-
-To set it up:
-
-1. **Add a Hue driver.** In the device's web UI pipeline (**Layers → a Layer → its Drivers**), add a **Hue** driver. Enter your bridge's IP in `bridgeIp` (find it in the Hue app, or at [discovery.meethue.com](https://discovery.meethue.com)).
-2. **Pair with the bridge.** Press the physical **link button** on the Hue bridge, then click the driver's **`pair`** button within ~30 seconds. The device claims an app key (stored on the driver as `appKey`) — a one-time step; the status line reports `paired, N lights`.
-3. **Pick what it drives.** The driver lists the bridge's colour-capable, reachable bulbs and its rooms; use the `room` / `light` controls to aim the effect at all bulbs, one room, or a single light. Each selected bulb becomes one pixel of the driver's window.
-4. **Run an effect.** Any effect on the layer now drives the bulbs — the global brightness slider and colour-order correction apply to them just like a physical strip (brightness 0 turns a bulb off).
-
-*Note:* a bulb is only driven if it's an "Extended color light" and currently reachable — a white-only bulb, a plug, or a powered-off light is skipped. For true real-time (fast) Hue, the [Hue Entertainment API](https://developers.meethue.com/develop/hue-entertainment/) (DTLS streaming) is a separate future path; today's driver targets the standard API's ambient-colour sweet spot.
-
-## Other platforms
-
-Homebridge above is the worked example; other home-automation platforms adopt the same device through the same primitives. The building blocks are shared — the [MQTT control surface](../moonmodules/core/services.md#mqtt) (broker + the `on`/`brightness`/`hsv` topics) and, for hubs that speak it, the device's WLED compatibility — so a new integration is a new front-end on top, not new device work.
-
-- **Home Assistant** — two ways, both zero-config:
- - **MQTT auto-discovery (recommended when you run a broker).** With the MQTT module's `haDiscovery` control on (the default) and a broker set, the device announces itself and HA **auto-creates a light entity** — no manual topic-matching, no YAML. Just add the MQTT integration in HA pointed at the same broker; the light appears (named after the device) with on/off + brightness, and greys out when the device drops offline. This is the Tasmota/ESPHome-style discovery.
- - **WLED integration (no broker needed).** HA's built-in WLED integration discovers the device over the WLED `/json` API it already serves (the same interface the WLED apps use), so on/off + brightness work with no MQTT at all.
- - *Note:* don't hand-configure HA's generic "MQTT light" against `homeassistant/light/…` — that's HA's own topic schema, which the device answers only via the auto-discovery above. If you ever configure MQTT by hand, point it at the device's own `projectMM//…` topics (see the [MQTT reference](../moonmodules/core/services.md#mqtt)).
-- **Others** (Node-RED, openHAB, voice assistants via a hub, …) — anything that speaks MQTT drives the device through the [control topics](../moonmodules/core/services.md#mqtt); the broker setup is the same [install step](#install-a-broker-homebridge) as above, only the consumer differs.
+## Troubleshooting
-Each integration is a self-contained recipe; the shared MQTT reference lives once in [Core › Services › MQTT](../moonmodules/core/services.md#mqtt).
+- **`mqtt_status` stuck on `connecting`** — the device can't reach the broker. Check the broker IP is your computer's *LAN* IP (not `127.0.0.1`, which the device can't route to), that the broker is actually listening on all interfaces (on Windows standalone Mosquitto, the `listener 1883 0.0.0.0` line above; the HA Mosquitto add-on already listens on all interfaces), and that the firewall isn't blocking `1883`.
+- **HA entities flap "unavailable" every few minutes** — the VM's network is dropping. Almost always the WiFi-bridge symptom noted in the HA install section: the HA VM is on a WiFi-bridged NIC that re-associates periodically. Move the HA VM to a wired external switch (Hyper-V / ProxMox / ESXi all support this), or run HAOS on a wired Pi. This is also what a battery of Zigbee / Wi-Fi routers with a bad channel plan looks like — worth checking the HA host's own connectivity first.
+- **HA created two light entities with the same slug (`light._`)** — a stale MQTT-discovery config is still retained on the broker. Delete the retained topic (`mosquitto_pub -h -t 'homeassistant/light/projectMM_/config' -r -n`) and let the device republish; HA cleans up the entity within a few seconds.
+- **HA WLED integration fails with "Cannot connect", or the entity shows partial state** — HA reads two endpoints, both on the device's HTTP port (default **80** on ESP32, **8080** on desktop builds), so probe both:
-## Troubleshooting
+ ```bash
+ curl http:///json # full {state, info, effects, palettes}
+ curl http:///presets.json # `{"0":{}}` — truthy-empty is correct
+ ```
-- **`mqtt_status` stuck on `connecting`** — the device can't reach the broker. Check the broker IP is your computer's *LAN* IP (not `127.0.0.1`, which the device can't route to), that the broker is actually listening on all interfaces (on Windows, the `listener 1883 0.0.0.0` line above), and that the firewall isn't blocking `1883`.
+ A **404 on `/json`** means an old firmware that predates the WLED-compatibility shim → reflash. A **404 on `/presets.json`** with `/json` working means the presets route is missing → HA's coordinator retry-storms trying to fetch presets and the entity ends up stuck on "unavailable" even though the light responds; also a firmware reflash. A `/json` response that parses but is missing `info.fs`, `state.nl`, `state.udpn`, or `state.lor` fails python-wled's dataclass parse and HA reports HTTP-500 on `light.turn_on` — again a firmware version older than the current WLED shim.
- **Homebridge shows "No Response"** — the accessory's topics don't match the device's MAC suffix, or the `url` points at the wrong broker. Confirm the suffix with `mosquitto_sub -t 'projectMM/#'` and that the same broker appears in both the device's `broker` control and the accessory `url`.
-- **Colour wheel doesn't match a specific colour** — expected: HomeKit sends a full-precision hue, and the device snaps it to the *nearest* built-in palette (there's no arbitrary-colour mode). See the palette note in the [MQTT reference](../moonmodules/core/services.md#mqtt).
+- **HomeKit colour wheel doesn't match a specific colour** — expected: HomeKit sends a full-precision hue, and the device snaps it to the *nearest* built-in palette (there's no arbitrary-colour mode). Same behaviour whether the bridge is HA's HomeKit Bridge or standalone Homebridge. See the palette note in the [MQTT reference](../moonmodules/core/services.md#mqtt).
diff --git a/mkdocs.yml b/mkdocs.yml
index ec264261..70e40470 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -146,6 +146,7 @@ nav:
- Testing strategy: testing.md
- Performance: performance.md
- Hardware reference:
+ - GPIO usage per MCU: reference/gpio-usage.md
- ESP32-S31 coreboard: reference/esp32-s31-coreboard.md
# The per-module summary pages above (Effects & building shows › Supporting,
# Core › UI/Supporting) are the docs-v2 surface: each 4-column table row links
diff --git a/moondeck/ci/verify_version.py b/moondeck/ci/verify_version.py
index 9151010b..29c8e745 100644
--- a/moondeck/ci/verify_version.py
+++ b/moondeck/ci/verify_version.py
@@ -30,6 +30,17 @@
import sys
from pathlib import Path
+# Force UTF-8 stdio on Windows: this script prints a `↔` (U+2194) in the OK
+# summary, and Python 3.14 on Windows defaults stdout to cp1252 which raises
+# UnicodeEncodeError on that character — the exact case Windows CI + the
+# host-side pytest hit (subprocess captures stderr, but the print crash makes
+# the script return 1 and the tag-matches tests fail). reconfigure() is a no-op
+# on POSIX where stdio is already UTF-8. Same fix as build/setup_esp_idf.py.
+if sys.stdout is not None:
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+if sys.stderr is not None:
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
+
ROOT = Path(__file__).resolve().parent.parent.parent
LIBRARY_JSON = ROOT / "library.json"
diff --git a/moondeck/scenario/run_scenario.py b/moondeck/scenario/run_scenario.py
index 105ad51a..a2084733 100644
--- a/moondeck/scenario/run_scenario.py
+++ b/moondeck/scenario/run_scenario.py
@@ -78,6 +78,22 @@ def _run_one(path: Path, update_contract: bool, update_reason: str | None) -> in
Symmetric with the live runner's behaviour — observations persist always,
contracts only when renegotiated."""
+ # Honour a scenario-level `skip_on` allowlist of host targets that lack a
+ # capability the scenario exercises (today: MoonLive scenarios opt out on
+ # pc-windows / pc-linux — the desktop JIT backend is arm64-only, so an
+ # x86_64 host renders dark and the "buffer non-zero" check would fail for
+ # a platform-capability reason the scenario isn't the right vehicle to
+ # assert. The C++ ctest suite gates the same tests on MM_MOONLIVE_HAS_HOST_JIT.
+ # An absent or empty `skip_on` runs everywhere, the existing default.
+ try:
+ with open(path, encoding="utf-8") as f:
+ scenario_meta = json.load(f)
+ except Exception:
+ scenario_meta = {}
+ target = _host_target()
+ if target in scenario_meta.get("skip_on", []):
+ print(f" SKIP {path.name} (skip_on {target})")
+ return 0
# Capture + tee: stream to stdout while collecting MEASURE lines.
proc = subprocess.Popen([str(RUNNER), str(path)], cwd=ROOT,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
diff --git a/src/core/AudioModule.h b/src/core/AudioModule.h
index d8e577c6..f02cbdaa 100644
--- a/src/core/AudioModule.h
+++ b/src/core/AudioModule.h
@@ -53,8 +53,8 @@ namespace mm {
/// **Hardware: INMP441-class digital mic.** A self-clocked I2S MEMS microphone:
/// standard/Philips framing, 24-bit data left-justified in a 32-bit slot, mono. The
/// part is self-clocked from the bit clock; there is no master-clock (MCLK) pin.
-/// The bench wiring is WS=4 (word-select/LRCLK), SD=5 (serial data out), SCK=6 (bit
-/// clock). It drives the one slot its L/R select pin chooses (tie L/R to GND for the
+/// The bench wiring is SCK=6 (bit clock), WS=4 (word-select/LRCLK), SD=5 (serial data
+/// out). It drives the one slot its L/R select pin chooses (tie L/R to GND for the
/// left slot); if `level` stays at the floor with sound present, the mic is filling
/// the other slot — one wire, not firmware.
///
@@ -104,10 +104,11 @@ class AudioModule : public MoonModule {
// control sentinel — so GPIO 0 stays a usable mic pin): the module is user-added
// when a board has a mic, and stays idle (no I2S init) until the user enters the
// real GPIOs, so adding it can't grab arbitrary pins or wedge a board with no
- // mic. The bench INMP441 wiring is WS=4 / SD=5 / SCK=6. ---
- int8_t wsPin = -1; ///< word-select / LRCLK (-1 = unset). Changing it re-creates the I2S channel live (no reboot).
- int8_t sdPin = -1; ///< serial data in (-1 = unset). Changing it re-creates the I2S channel live.
- int8_t sckPin = -1; ///< bit clock (-1 = unset). Changing it re-creates the I2S channel live.
+ // mic. The bench INMP441 wiring is SCK=6 / WS=4 / SD=5. Order follows the I2S
+ // datasheet convention: clocks (SCK, WS) then data (SD) then the optional MCLK. ---
+ int8_t sckPin = -1; ///< bit clock / BCLK (-1 = unset). Changing it re-creates the I2S channel live (no reboot).
+ int8_t wsPin = -1; ///< word-select / LRCLK (-1 = unset). Changing it re-creates the I2S channel live.
+ int8_t sdPin = -1; ///< serial data in / DOUT (-1 = unset). Changing it re-creates the I2S channel live.
int8_t mclkPin = -1; ///< master clock out (-1 = none). Self-clocked MEMS mics (INMP441) leave this
///< -1; an ADC/codec that needs a master clock (e.g. the MHC-WLED P4 shield's
///< line-in ADC on GPIO3) sets it so the I2S peripheral drives MCLK. On a codec
@@ -153,9 +154,9 @@ class AudioModule : public MoonModule {
? sampleRateSel : 2]; }
void onBuildControls() override {
+ controls_.addPin("sckPin", sckPin);
controls_.addPin("wsPin", wsPin);
controls_.addPin("sdPin", sdPin);
- controls_.addPin("sckPin", sckPin);
controls_.addPin("mclkPin", mclkPin);
static constexpr const char* kRateOptions[] = {"8000", "16000", "22050", "44100"};
controls_.addSelect("sampleRate", sampleRateSel, kRateOptions, kSampleRateCount);
@@ -290,6 +291,13 @@ class AudioModule : public MoonModule {
// than one render tick), so each tick contributes a partial; we analyse
// once the accumulator is full, then reset it.
const size_t n = platform::audioMicRead(mic_, samples_ + filled_, kBlock - filled_);
+ // Mic-health tallies (read here, before the DC-blocker rewrites samples_): the samples the I2S
+ // read delivered, and whether any were non-zero, accumulated over the 1 s window and diagnosed
+ // in loop1s(). The two together isolate the two clean failure modes of a mic bring-up: samples
+ // arriving proves the clocks (BCLK/WS) + DMA; a non-zero among them proves the data line (SD).
+ // All-zero samples = clocks fine, SD dead — otherwise a silent "RMS 0" with no clue which wire.
+ micSamples1s_ += n;
+ for (size_t i = 0; i < n; i++) if (samples_[filled_ + i] != 0) { micNonzero1s_++; break; }
if (n == 0) return; // nothing ready this tick
filled_ += n;
if (filled_ < kBlock) return; // wait for a whole block
@@ -389,6 +397,26 @@ class AudioModule : public MoonModule {
std::snprintf(levelStr_, sizeof(levelStr_), "%u", static_cast(levelPeak_));
std::snprintf(peakStr_, sizeof(peakStr_), "%u Hz", static_cast(frame_.peakHz));
levelPeak_ = 0; // reset for the next window
+
+ // Mic-health diagnosis from the 1 s tallies (see the read path). Only for a live *direct* mic
+ // (inited, not a sync receiver, no codec) — a codec/sync/unset-pin path has its own status. The
+ // split turns a silent mic into a wire-specific verdict:
+ // no samples at all → the I2S clocks aren't running — check sckPin / wsPin.
+ // samples, all zero → clocks fine, data line dead — check sdPin (SD/DOUT) + power.
+ // samples, non-zero → data is flowing; clear any prior diagnosis.
+ const bool directMicLive = inited_ && sync != 2
+ && platform::audioCodecType == platform::CodecType::None;
+ if (directMicLive) {
+ if (micSamples1s_ == 0)
+ setStatus("mic: no samples — check sckPin / wsPin (I2S clocks)", Severity::Warning);
+ else if (micNonzero1s_ == 0)
+ setStatus("mic: data line silent — check sdPin (SD/DOUT) + mic power", Severity::Warning);
+ else if (micStatusStale_)
+ setStatus("", Severity::Status); // data flowing again — clear a prior diagnosis
+ micStatusStale_ = (micSamples1s_ == 0 || micNonzero1s_ == 0);
+ }
+ micSamples1s_ = 0;
+ micNonzero1s_ = 0;
// Live sync status: "sending" / "receiving" (peer audio fresh) / "listening"
// (bound, no peer) / "off". While the socket isn't open yet, leave the baseline
// syncReinit/syncEnsureSocket set ("waiting for network" / "…failed"); only once
@@ -428,6 +456,14 @@ class AudioModule : public MoonModule {
char peakStr_[12] = {};
uint8_t levelPeak_ = 0; // peak frame_.level across the current 1 s display window (UI only)
+ // Mic-health tallies over the 1 s window (see the read path + loop1s diagnosis). micSamples1s_ =
+ // raw samples the I2S read delivered (proves clocks/DMA); micNonzero1s_ = reads that carried a
+ // non-zero sample (proves the SD data line). micStatusStale_ = a diagnosis is currently posted, so
+ // loop1s clears it once the mic recovers rather than clearing every second.
+ uint32_t micSamples1s_ = 0;
+ uint32_t micNonzero1s_ = 0;
+ bool micStatusStale_ = false;
+
// Auto fill-in (simulate 1/2): treat the mic as "live" for a grace window after any block above
// the threshold, so brief gaps between beats don't flip to the sim. ~2 s of blocks (≈86/block·23ms).
static constexpr uint16_t kSimRealThreshold = 4; // a block level above this counts as real sound
@@ -463,8 +499,8 @@ class AudioModule : public MoonModule {
// don't attempt an I2S init — initialising I2S on unset pins is what hung a
// mic-less board's boot. GPIO 0 IS a valid mic pin now (the sentinel is -1,
// not 0), so the guard tests < 0, not == 0.
- if (wsPin < 0 || sdPin < 0 || sckPin < 0) {
- setStatus("mic: set wsPin / sdPin / sckPin", Severity::Status);
+ if (sckPin < 0 || wsPin < 0 || sdPin < 0) {
+ setStatus("mic: set sckPin / wsPin / sdPin", Severity::Status);
return;
}
// Bring up the I2S channel FIRST. Where MCLK comes from depends on the board:
diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp
index f44e1b24..56c08fa5 100644
--- a/src/core/HttpServerModule.cpp
+++ b/src/core/HttpServerModule.cpp
@@ -15,6 +15,11 @@
#include "core/FilesystemModule.h"
#include "core/FirmwareUpdateModule.h"
#include "core/SystemModule.h" // deviceName() for the WLED /json/info shim
+#include "light/Palette.h" // Palettes::nearestForHue — maps HA's RGB colour picker onto our
+ // hue→palette convention (same core→light bridge MqttModule uses
+ // for hsv/set; see the note in MqttModule.cpp:7-14).
+#include "light/drivers/Drivers.h" // Drivers::latestSummary() — the real light count/channels for
+ // the WLED /json shim (same one-narrow-reach as Palette above).
#include "platform/platform.h"
#include "ui/ui_embedded.h"
@@ -217,6 +222,21 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) {
// as the card tint). serveWledState reads live brightness from the Drivers module.
else if (std::strcmp(path, "/json/state") == 0) serveWledState(conn);
else if (std::strcmp(path, "/json/si") == 0) serveWledStateInfo(conn);
+ // Home Assistant's WLED integration fetches `/json` (the full combined blob, not `/json/si`),
+ // and its Python `wled` library rejects a response missing any of Info.fs, State.nl,
+ // State.udpn, State.lor — so the `/json/info` + `/json/state` shim (tuned to the WLED Android
+ // app's minimal Moshi model) can't answer this endpoint. serveWledDeviceJson writes the
+ // fuller shape python-wled parses; /json/info and /json/state stay minimal (Android-app path).
+ else if (std::strcmp(path, "/json") == 0) serveWledDeviceJson(conn);
+ // /presets.json — the second endpoint HA's WLED lib fetches after /json (on every state
+ // update where info.uptime/info.fs.pmt are zero — see python-wled's _check_presets_changed).
+ // If it 404s, python-wled raises WLEDEmptyResponseError and HA's config flow aborts with
+ // HTTP 500. We don't implement WLED presets, so return a TRUTHY-but-empty presets object
+ // (`{"0":{}}`): python-wled's __pre_deserialize__ maps it into `{0: Preset(0)}` then discards
+ // 0 per its "Nobody cares about 0" rule — result is HA seeing zero presets. `{}` alone would
+ // fail the `not presets` guard in wled.py; we need a non-empty dict.
+ else if (std::strcmp(path, "/presets.json") == 0)
+ sendResponse(conn, 200, "application/json", "{\"0\":{}}");
else sendResponse(conn, 404, "text/plain", "Not found");
} else if (std::strcmp(method, "POST") == 0) {
// POST /api/modules//move with body {"to":N}.
@@ -971,16 +991,9 @@ void HttpServerModule::serveWledInfo(platform::TcpConnection& conn) {
conn.write(reinterpret_cast(header), std::strlen(header));
// Identity: the deviceName (from SystemModule), the live IP, the MAC.
- const char* name = "projectMM";
- if (MoonModule* sys = findModuleByName("System")) {
- const char* dn = static_cast(sys)->deviceName();
- if (dn && dn[0]) name = dn;
- }
- uint8_t ip[4] = {};
- platform::ethGetIPv4(ip);
- if (!ip[0] && !ip[1] && !ip[2] && !ip[3]) platform::wifiStaGetIPv4(ip);
- uint8_t mac[6] = {};
- platform::getMacAddress(mac);
+ const char* name; uint8_t mac[6]; uint8_t ip[4];
+ resolveWledIdentity(name, mac, ip);
+ (void)ip; // serveWledInfo doesn't need the IP; keep the call uniform.
// Field set reverse-engineered from the WLED-Android app's `Info` Moshi model
// (model/wledapi/Info.kt): the ONLY non-nullable fields it requires are `name`, `leds`
@@ -997,40 +1010,102 @@ void HttpServerModule::serveWledInfo(platform::TcpConnection& conn) {
sink.flush();
}
+// See header. Extracts the deviceName / IP / MAC lookup the WLED shim needs at four
+// call sites (/json/info, /json/state /json/si, /json), so a future change to how identity
+// is discovered updates one place.
+void HttpServerModule::resolveWledIdentity(const char*& name, uint8_t mac[6], uint8_t ip[4],
+ const char* nameFallback) {
+ name = nameFallback;
+ if (MoonModule* sys = findModuleByName("System")) {
+ const char* dn = static_cast(sys)->deviceName();
+ if (dn && dn[0]) name = dn;
+ }
+ for (int i = 0; i < 6; i++) mac[i] = 0;
+ platform::getMacAddress(mac);
+ for (int i = 0; i < 4; i++) ip[i] = 0;
+ platform::ethGetIPv4(ip);
+ if (!ip[0] && !ip[1] && !ip[2] && !ip[3]) platform::wifiStaGetIPv4(ip);
+}
+
// The WLED info object, written into an open sink (no HTTP header). Shared by
// /json/info and the `info` half of /json/si.
+// Emit the WLED `name` field with the 💫 projectMM marker prefixed, so a projectMM board stands out
+// among plain WLED devices in Home Assistant's device list (which keys everything off the WLED
+// integration). The marker lives ONLY in the WLED-compat name HA reads — the real deviceName (UI,
+// mDNS hostname, MQTT topics) stays unprefixed, so identity/hostnames carry no emoji. writeJsonString
+// owns the quotes + escaping; the marker is a plain UTF-8 literal that passes through unescaped.
+void HttpServerModule::writeWledName(JsonSink& sink, const char* name) {
+ char prefixed[80];
+ std::snprintf(prefixed, sizeof(prefixed), "\xF0\x9F\x92\xAB %s", name ? name : "");
+ sink.writeJsonString(prefixed);
+}
+
void HttpServerModule::writeWledInfoBody(JsonSink& sink, const char* name, const uint8_t mac[6]) {
sink.appendf("{\"name\":");
- sink.writeJsonString(name); // writes its own surrounding quotes + escaping
- // wifi: rssi/signal are nullable in the model, but sending them makes the app show a
- // signal icon instead of a crossed-out one (cosmetic — the device lists either way).
+ writeWledName(sink, name);
+ // Real led count (the light domain's Drivers::latestSummary) + wifi rssi/signal, so the WLED
+ // app card and the WS push show the true device shape. signal maps rssi→0-100 like WLED.
+ const unsigned ledCount = Drivers::latestSummary()->lightCount;
+ const int rssi = platform::wifiStaRssi();
+ int signal = (rssi == 0) ? 0 : (2 * (rssi + 100));
+ if (signal < 0) signal = 0; else if (signal > 100) signal = 100;
sink.appendf(",\"mac\":\"%02x%02x%02x%02x%02x%02x\","
- "\"leds\":{\"count\":1},\"wifi\":{\"rssi\":-50,\"signal\":100},"
+ "\"leds\":{\"count\":%u},\"wifi\":{\"rssi\":%d,\"signal\":%d},"
"\"brand\":\"WLED\",\"product\":\"MoonModules\"}",
- mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
+ mac[0], mac[1], mac[2], mac[3], mac[4], mac[5],
+ ledCount, rssi, signal);
}
-// Current Drivers brightness (0–255), or 0 if Drivers isn't present. This is the global
-// output brightness the WLED app's slider maps to. Read generically through the control
-// list (by name + Uint8 type, via the stored pointer) so this core module needs no
-// light-domain include — the same domain-neutral reach applySetControl uses to write.
-// The WLED state object, written into an open sink. `on` + `bri` mirror Drivers
-// brightness (off = brightness 0). `seg[0].col[0]` is the colour the WLED app tints the
-// device card with: we send the LIVE first-LED RGB from Drivers, so the card mirrors what
-// the device is actually showing — falling back to projectMM purple when the first LED is
-// black/off or there's no output (so a dark device still reads as a distinct projectMM,
-// not an indistinct black card).
+// The WLED state object, written into an open sink. `on` + `bri` mirror Drivers on/brightness.
+// `seg[0].col[0]` reports the ACTIVE PALETTE's identity colour, not the live first-LED — so
+// every WLED consumer (the WLED native app's device card, HA's WLED integration colour picker,
+// Homebridge's HSV via the MQTT pair, the /ws push) sees the same stable palette-representative
+// value and matches the palette-picker → RGB round-trip. Live first-LED was tried first and
+// dropped: it dimmed the picker under low master brightness (near-black) and jittered with the
+// effect animation ("the picked color moves" — user report). `Palettes::representativeRgb`
+// returns V=255, so brightness stays HA's `state.bri × seg.bri` responsibility and doesn't
+// double-dim. Rationale for the seg[0].on / seg[0].bri fields lives inline below.
void HttpServerModule::writeWledStateBody(JsonSink& sink) {
const uint8_t bri = driversBrightness(scheduler_);
- uint8_t rgb[3] = {0, 0, 0};
- bool haveLed = false;
- if (MoonModule* d = findModuleByName("Drivers")) haveLed = d->firstOutputRgb(rgb);
- if (!haveLed || (rgb[0] == 0 && rgb[1] == 0 && rgb[2] == 0)) {
- rgb[0] = 128; rgb[1] = 0; rgb[2] = 255; // projectMM purple — the black/off default
- }
- sink.appendf("{\"on\":%s,\"bri\":%u,"
- "\"seg\":[{\"id\":0,\"col\":[[%u,%u,%u]]}]}",
- driversOn(scheduler_) ? "true" : "false", bri, rgb[0], rgb[1], rgb[2]);
+ const RGB pc = Palettes::representativeRgb(driversPalette(scheduler_));
+ // nl/udpn/lor/transition/ps/pl/mainseg are additive to the Android-app minimum (Moshi ignores
+ // unknown/extra fields), and REQUIRED for HA's WLED integration: `python-wled` parses the POST
+ // /json/state response through State.from_dict too — the same required-fields contract as /json.
+ // Without them, HA `light.turn_on` succeeds on the device but the response parse raises, which HA
+ // wraps as HTTP 500 on `services/light/turn_on`. nl/udpn as empty objects satisfy the parser via
+ // their dataclass defaults; lor=0 is LiveDataOverride.OFF.
+ // seg[0].on MUST be present: HA WLED's is_on for a WLEDSegmentLight reads
+ // state.segments[].on (light.py:244), NOT top-level state.on. Without it,
+ // python-wled parses segment.on as its dataclass default None, `bool(None)` is
+ // False, and HA's UI shows the light off even when the device is on — the
+ // "brightness/color work but the toggle doesn't" symptom pinned on the bench.
+ const char* onStr = driversOn(scheduler_) ? "true" : "false";
+ // seg[0].pal = the active palette index, so HA's WLED integration highlights the current entry
+ // in its palette dropdown (light.py reads state.segments[].palette). It shares the Drivers
+ // `palette` control with col[0] above (representativeRgb of the SAME index), so the HA palette
+ // dropdown and colour picker stay two views of one value: selecting a palette repaints the picker
+ // on HA's next poll, and picking a colour snaps to the nearest palette (applyWledState).
+ const uint8_t pal = driversPalette(scheduler_);
+ sink.appendf("{\"on\":%s,\"bri\":%u,\"transition\":7,\"ps\":-1,\"pl\":-1,"
+ "\"nl\":{},\"udpn\":{},\"lor\":0,\"mainseg\":0,"
+ // seg[0].bri MUST be present alongside seg[0].on (same reason): HA WLED reads brightness
+ // from state.segments[].brightness (light.py's _attr_brightness), NOT top-level
+ // state.bri. Without it python-wled parses segment.brightness as the dataclass default
+ // 0, so HA renders the slider at zero even when the device is at full. Same on-the-
+ // bench root-cause as seg[0].on — HA's SegmentLight class reads *segment* fields.
+ // seg[0].bri = 255 (segment is 100% of master), state.bri = actual — the real WLED
+ // convention. HA WLEDSegmentLight with the default has_main_light=False computes
+ // (segment.bri × state.bri) / 255 (coordinator.py + light.py:220-222), so sending
+ // 255 in the segment lets HA render the actual master value. Sending `bri` in both
+ // would show bri²/255 instead — verified against ha-core wled/coordinator.py.
+ // fx=0 accompanies pal: a real WLED segment always reports BOTH the effect and the
+ // palette index, and python-wled's Segment model (HA's WLED integration) pairs them —
+ // sending pal without fx yields a half-populated segment real WLED never produces, and
+ // HA's light-platform setup then leaves the light entity stuck `restored`/unavailable
+ // (the sensors still work — only the segment-derived light breaks). fx=0 = "Solid", the
+ // single effect this shim exposes (fxcount=1), so the pair is consistent.
+ "\"seg\":[{\"id\":0,\"on\":%s,\"bri\":255,\"fx\":0,\"pal\":%u,\"col\":[[%u,%u,%u]]}]}",
+ onStr, bri, onStr, pal, pc.r, pc.g, pc.b);
}
void HttpServerModule::serveWledState(platform::TcpConnection& conn) {
@@ -1043,6 +1118,121 @@ void HttpServerModule::serveWledState(platform::TcpConnection& conn) {
sink.flush();
}
+// /json — the FULL combined blob Home Assistant's WLED integration fetches (frenck/python-wled). The
+// crucial deltas from /json/si (which targets the WLED Android app's minimal Moshi model): python-wled
+// requires `info.fs` (Filesystem), `state.nl` (Nightlight), `state.udpn` (UDPSync), and `state.lor`
+// (LiveDataOverride) — every other field carries a default in the dataclass and is optional. We also
+// send `ver >= "0.14.0"` because python-wled's __pre_deserialize__ raises WLEDUnsupportedVersionError
+// on anything below (skipped only when `ver` is absent, but sending it makes HA's update-badge behave).
+// `effects` and `palettes` each carry one entry so HA renders a one-option picker rather than none.
+// Independent of /json/info + /json/state so THIS surface can grow to satisfy python-wled without
+// disturbing the Android-app validated minimum. Prior art: WLED's own /json response shape (public
+// docs at https://kno.wled.ge/interfaces/json-api/); we write ours fresh against the model dataclass
+// contract, not by copying WLED's implementation.
+void HttpServerModule::serveWledDeviceJson(platform::TcpConnection& conn) {
+ const char* header =
+ "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
+ "Connection: close\r\nAccess-Control-Allow-Origin: *\r\n\r\n";
+ conn.write(reinterpret_cast(header), std::strlen(header));
+
+ const char* name; uint8_t mac[6]; uint8_t ip[4];
+ resolveWledIdentity(name, mac, ip);
+
+ JsonSink sink(conn);
+ // state — writeWledStateBody emits the {on,bri,seg,...} block reused by /json/state and
+ // /json/si; wrap it under "state":. Keeping one authoritative writer avoids the two paths
+ // drifting on which seg[0] fields HA actually reads.
+ sink.appendf("{\"state\":");
+ writeWledStateBody(sink);
+ // info — `ver` is a sentinel `"99.0.0"`, NOT the projectMM semver. Reason: HA's WLED
+ // integration parses WLED tags as CalVer (`16.0.1` is year-16, not `0.16.1`), so a
+ // projectMM semver like `2.1.0-dev` compares LOWER than WLED's current `16.0.1` (2 < 16)
+ // and HA flags a bogus "update to WLED 16.0.1" whose `.bin` would brick a projectMM
+ // device. `AwesomeVersion("99.0.0") > AwesomeVersion("")` in the CalVer
+ // regime, so HA's WLED update-check is always silent for us. First tried `mm::kVersion`
+ // (assuming SemVer parsing) — the bench P4 showed HA still flagging 16.0.1 after the flash
+ // because the CalVer branch was the actual one taken. Real projectMM version lives on the
+ // MQTT `update/state` topic (`installed_version` under the HA update entity), which is where
+ // "did projectMM ship a new release" belongs — the WLED shim is for the LIGHT ENTITY, not
+ // the firmware version. `arch`/`brand`/`product`/`mac`/`ip` populate HA's device card
+ // (mf/mdl/sw_version rendered from these); `leds`/`wifi`/`fs` are the objects python-wled's
+ // Info dataclass requires or expects for the sensor entities (heap, uptime, signal).
+ // Real values for the diagnostic sensors HA renders from the `wifi` + `freeheap` blocks.
+ // signal maps rssi→0-100 the way WLED does (0 at -100 dBm, 100 at -50 dBm); bssid/channel come
+ // from the associated AP. On an ETHERNET device there is no Wi-Fi AP, so these read 0/empty — and
+ // `info.wifi` is Optional in python-wled, so we OMIT the whole `wifi` object rather than send a
+ // zeroed one. HA then creates no Wi-Fi sensors for an eth device (a real WLED-on-eth behaves the
+ // same), instead of the greyed "Wi-Fi RSSI/BSSID/channel/signal" rows an all-zero block produces.
+ const bool onEth = platform::ethConnected();
+ const int rssi = platform::wifiStaRssi();
+ uint8_t bssid[6] = {};
+ platform::wifiStaBssid(bssid);
+ const int channel = platform::wifiStaChannel();
+ int signal = (rssi == 0) ? 0 : (2 * (rssi + 100));
+ if (signal < 0) signal = 0; else if (signal > 100) signal = 100;
+ // Real pipeline shape from the light domain (Drivers::latestSummary) + render rate.
+ const LightSummary* ls = Drivers::latestSummary();
+ const unsigned ledCount = ls->lightCount;
+ const unsigned renderFps = scheduler_ ? scheduler_->fps() : 0;
+ const char* rgbw = (ls->channelsPerLight >= 4) ? "true" : "false";
+ sink.appendf(",\"info\":{\"ver\":\"99.0.0\",\"vid\":2410150,\"name\":");
+ writeWledName(sink, name);
+ sink.appendf(",\"mac\":\"%02x%02x%02x%02x%02x%02x\","
+ "\"ip\":\"%u.%u.%u.%u\",\"arch\":\"esp32\","
+ "\"brand\":\"WLED\",\"product\":\"MoonModules\",\"release\":\"MoonModules\","
+ // lc + seglc = LightCapability.RGB_COLOR (1) so HA WLED's segment light picks
+ // ColorMode.RGB (via LIGHT_CAPABILITIES_COLOR_MODE_MAPPING in ha-core/wled/const.py),
+ // which grants a brightness slider AND colour picker. LightCapability.NONE (0)
+ // maps to ColorMode.ONOFF, which is why the entity was on/off-only initially.
+ // BOTH are capability CODES, not counts: HA reads seglc[segment_id] as that segment's
+ // capability bitmask (1 = RGB), then LIGHT_CAPABILITIES_COLOR_MODE_MAPPING[seglc[0]]
+ // gives the colour mode. Putting the LED count here (e.g. seglc:[24]) has no mapping,
+ // so WLEDSegmentLight ends up with NO supported colour modes and HA refuses to add the
+ // light entity ("does not set supported color modes") — it stays `restored`/unavailable
+ // while the sensors still work. seglc is therefore the constant 1, matching lc; the LED
+ // count lives only in `count`. fps = the real render rate (scheduler_->fps()).
+ "\"leds\":{\"count\":%u,\"fps\":%u,\"rgbw\":%s,\"wv\":false,\"cct\":false,"
+ "\"maxpwr\":0,\"maxseg\":1,\"pwr\":0,\"lc\":1,\"seglc\":[1]},",
+ mac[0], mac[1], mac[2], mac[3], mac[4], mac[5],
+ ip[0], ip[1], ip[2], ip[3],
+ ledCount, renderFps, rgbw);
+ // wifi — only for a Wi-Fi device (omitted on Ethernet; see the comment above the getters).
+ if (!onEth) {
+ sink.appendf("\"wifi\":{\"bssid\":\"%02x:%02x:%02x:%02x:%02x:%02x\","
+ "\"rssi\":%d,\"channel\":%d,\"signal\":%d},",
+ bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5],
+ rssi, channel, signal);
+ }
+ sink.appendf("\"fs\":{\"t\":256,\"u\":32,\"pmt\":1},"
+ // uptime + pmt drive python-wled's presets change-detect: when both are non-zero and
+ // stable, HA computes a stable "boot_time" and stops refetching /presets.json every
+ // state update. pmt=1 (stable-since-boot) + uptime in seconds gives it the anchor.
+ "\"freeheap\":%u,\"uptime\":%u,\"udpport\":21324,\"live\":false,"
+ // ws=-1 tells python-wled (HA's WLED integration lib) that WebSocket updates are
+ // unsupported in this build. Its __post_deserialize__ maps -1 to None, and its
+ // coordinator falls back to HTTP polling. Sending 0 (the WLED convention for
+ // "supported, no clients yet") makes HA open a WS to our own /ws endpoint, which
+ // serves projectMM-native state frames — not the WLED-shaped Info+State updates the
+ // python-wled parser requires — and floods HA's log with `MissingField: filesystem`
+ // on every frame. Fix pinned on the bench with `sudo docker logs homeassistant`.
+ "\"lm\":\"\",\"lip\":\"\",\"ws\":-1,"
+ // palcount = the real built-in count (matches the palettes[] array below); fxcount
+ // stays 1 (this shim exposes one effect surface). cpal/umpal = 0 (no custom palettes).
+ "\"fxcount\":1,\"palcount\":%u,\"cpalcount\":0,\"umpalcount\":0,\"str\":false}",
+ static_cast(platform::freeHeap()),
+ static_cast(platform::millis() / 1000u),
+ static_cast(mm::palettes::kCount));
+ // effects + palettes — python-wled's __pre_deserialize__ turns each array into an indexed dict.
+ // effects stays one real entry ("Solid"): this shim drives a single Layer, so a longer effect list
+ // would be a lie. palettes is the REAL built-in list (Palette.h paletteNames / kBuiltins) so HA's
+ // palette dropdown offers every palette the device has, indexed to match seg[0].pal and the Drivers
+ // `palette` control — the same one-narrow-reach into light/ that the representative colour uses.
+ sink.appendf(",\"effects\":[\"Solid\"],\"palettes\":[");
+ mm::paletteNames(sink);
+ sink.appendf("]}");
+ sink.flush();
+}
+
// /json/si — the combined {state, info} the WLED app reads in one call for its card.
void HttpServerModule::serveWledStateInfo(platform::TcpConnection& conn) {
const char* header =
@@ -1050,13 +1240,9 @@ void HttpServerModule::serveWledStateInfo(platform::TcpConnection& conn) {
"Connection: close\r\nAccess-Control-Allow-Origin: *\r\n\r\n";
conn.write(reinterpret_cast(header), std::strlen(header));
- const char* name = "projectMM";
- if (MoonModule* sys = findModuleByName("System")) {
- const char* dn = static_cast(sys)->deviceName();
- if (dn && dn[0]) name = dn;
- }
- uint8_t mac[6] = {};
- platform::getMacAddress(mac);
+ const char* name; uint8_t mac[6]; uint8_t ip[4];
+ resolveWledIdentity(name, mac, ip);
+ (void)ip; // /json/si's info body carries no IP field; keep the call uniform.
JsonSink sink(conn);
sink.appendf("{\"state\":");
@@ -1084,6 +1270,39 @@ void HttpServerModule::applyWledState(const char* body) {
std::snprintf(valueJson, sizeof(valueJson), "{\"value\":%d}", bri);
applySetControl("Drivers", "brightness", valueJson);
}
+ // WLED palette: seg[0].pal is the palette index. HA's WLED integration writes here when a user
+ // picks from the palette dropdown (the entries served by paletteNames in /json). It maps straight
+ // to the Drivers `palette` control — the direct-index counterpart to the col[] nearest-match below;
+ // both feed the same control, so the dropdown and the colour picker stay one value. Parsed from the
+ // segment object so a top-level stray "pal" can't hijack it.
+ const char* segStart = std::strstr(body, "\"seg\":");
+ const char* palStart = segStart ? std::strstr(segStart, "\"pal\":") : nullptr;
+ if (palStart) {
+ int pal = std::atoi(palStart + 6);
+ if (pal < 0) pal = 0;
+ if (pal >= mm::palettes::kCount) pal = mm::palettes::kCount - 1;
+ char valueJson[24];
+ std::snprintf(valueJson, sizeof(valueJson), "{\"value\":%d}", pal);
+ applySetControl("Drivers", "palette", valueJson);
+ }
+ // WLED colour: seg[0].col[0] is [r,g,b]. HA's WLED integration writes here when a user picks a
+ // colour in the RGB picker. Palettes::nearestForRgb is the canonical RGB→palette entry (see the
+ // comment at its declaration): it applies the same RGB→(hue,sat) conversion representativeHueSat
+ // uses on the palette side, then runs the 2D-distance sweep. Value channel is ignored — HA's own
+ // brightness slider handles bri via the `bri` field above.
+ const char* colStart = std::strstr(body, "\"col\":[[");
+ if (colStart) {
+ int r = 0, g = 0, b = 0;
+ if (std::sscanf(colStart + 8, "%d,%d,%d", &r, &g, &b) == 3) {
+ const uint8_t rc = static_cast(r < 0 ? 0 : (r > 255 ? 255 : r));
+ const uint8_t gc = static_cast(g < 0 ? 0 : (g > 255 ? 255 : g));
+ const uint8_t bc = static_cast(b < 0 ? 0 : (b > 255 ? 255 : b));
+ const uint8_t idx = mm::Palettes::nearestForRgb(rc, gc, bc);
+ char valueJson[24];
+ std::snprintf(valueJson, sizeof(valueJson), "{\"value\":%u}", static_cast(idx));
+ applySetControl("Drivers", "palette", valueJson);
+ }
+ }
}
// POST /json/state — the WLED app's HTTP control channel (its system quick-tiles + Home
@@ -1633,13 +1852,9 @@ void HttpServerModule::pushWledStateToWebSockets() {
for (auto& ws : wsClients_) if (ws.valid()) { hasClients = true; break; }
if (!hasClients) return;
- const char* name = "projectMM";
- if (MoonModule* sys = findModuleByName("System")) {
- const char* dn = static_cast(sys)->deviceName();
- if (dn && dn[0]) name = dn;
- }
- uint8_t mac[6] = {};
- platform::getMacAddress(mac);
+ const char* name; uint8_t mac[6]; uint8_t ip[4];
+ resolveWledIdentity(name, mac, ip);
+ (void)ip; // the WS-push info body carries no IP field; keep the call uniform.
JsonSink sink;
sink.appendf("{\"state\":");
diff --git a/src/core/HttpServerModule.h b/src/core/HttpServerModule.h
index 9adb1d2c..caa3cff0 100644
--- a/src/core/HttpServerModule.h
+++ b/src/core/HttpServerModule.h
@@ -13,9 +13,15 @@ class JsonSink;
class Scheduler;
/// Embedded HTTP server plus WebSocket — serves the web UI and the REST API that backs it.
-/// Core infrastructure with NO light-domain dependencies (no `PreviewFrame`, no light types,
-/// no light includes). Implementation lives in HttpServerModule.cpp; this header is the
-/// interface only. The `port` control defaults to 8080 on desktop, 80 on ESP32.
+/// Core infrastructure held to a **light-include-free** contract with one PO-accepted
+/// exception: the WLED-compatibility shim's colour path uses `light/Palette.h`'s pure
+/// hue/RGB↔palette-index conversions (`Palettes::nearestForRgb`, `Palettes::representativeRgb`),
+/// the same sanctioned exception `MqttModule` documents at its top-of-file — routing a HomeKit /
+/// HA WLED colour to a projectMM palette needs the palette set, which is inherently light-domain,
+/// and a format conversion is the least-coupling way to bridge it (this module still drives the
+/// palette through `Scheduler::setControl`, not a light object). No other light-domain include
+/// is permitted here. Implementation lives in HttpServerModule.cpp; this header is the interface
+/// only. The `port` control defaults to 8080 on desktop, 80 on ESP32.
///
/// **REST API:** `GET /` serves index.html and the UI assets (`/app.js`, `/style.css`,
/// `/moonlight-logo.png`). `GET /api/state` returns the full module-tree JSON (each entry
@@ -272,10 +278,20 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster {
void serveWledInfo(platform::TcpConnection& conn);
void serveWledState(platform::TcpConnection& conn);
void serveWledStateInfo(platform::TcpConnection& conn);
+ void serveWledDeviceJson(platform::TcpConnection& conn); ///< /json — HA WLED integration surface
void handleWledState(platform::TcpConnection& conn, const char* body);
void pollWledStateFromWebSockets(); ///< read app's slider/toggle sent over /ws
void writeWledInfoBody(JsonSink& sink, const char* name, const uint8_t mac[6]);
+ void writeWledName(JsonSink& sink, const char* name); // 💫-prefixed WLED name (HA marker)
void writeWledStateBody(JsonSink& sink);
+ /// Resolve device identity for the WLED shim: `deviceName` (from SystemModule) → `name`,
+ /// live IPv4 (Ethernet first, WiFi fallback) → `ip`, MAC → `mac`. Extracted so the
+ /// /json/info, /json/si and /json handlers share one lookup instead of hand-copying the
+ /// same four platform calls. `nameFallback` defaults to `"projectMM"` (the value the
+ /// handlers used before extraction). Both `ip` and `mac` are written in place; `name`
+ /// points at the SystemModule string when present (which outlives the request).
+ void resolveWledIdentity(const char*& name, uint8_t mac[6], uint8_t ip[4],
+ const char* nameFallback = "projectMM");
void writeModuleMetricsJson(JsonSink& sink, MoonModule* mod, bool& first);
// -----------------------------------------------------------------------
diff --git a/src/core/LightSummary.h b/src/core/LightSummary.h
new file mode 100644
index 00000000..43caffc1
--- /dev/null
+++ b/src/core/LightSummary.h
@@ -0,0 +1,26 @@
+#pragma once
+
+#include
+
+namespace mm {
+
+/// A small plain-data summary of the light pipeline's output, produced by the light domain
+/// (the `Drivers` container) and consumed by domain-neutral core consumers — the WLED `/json`
+/// shim and MQTT — so they can report the real device shape without including any light-domain
+/// class or type. This is the shared-struct pull pattern (see architecture.md § Data exchange,
+/// the same shape as `AudioFrame`): the producer owns one POD overwritten in place on each
+/// rebuild, the consumer holds a `const LightSummary*` and reads it; no allocation, no event bus.
+///
+/// Domain-neutral on purpose: plain `uint32_t`/`uint8_t`, no light typedefs, so it stays in core
+/// with the consumers. `lightCount` is `uint32_t` to hold any count on any board (the light-side
+/// `nrOfLightsType` is `uint16_t` without PSRAM, `uint32_t` with; `uint32_t` here covers both).
+///
+/// **Extendable by design.** Add a field (estimated power, segment count, RGBW flag) and the
+/// producer fills it in one place; every consumer that wants it reads it, no new seam. Keep it a
+/// flat POD of small integers.
+struct LightSummary {
+ uint32_t lightCount = 0; ///< total physical lights driven (Layer::physicalLightCount()).
+ uint8_t channelsPerLight = 3; ///< 3 = RGB, 4 = RGBW, more = multi-channel DMX fixtures.
+};
+
+} // namespace mm
diff --git a/src/core/MoonModule.h b/src/core/MoonModule.h
index d680c611..3a01bc16 100644
--- a/src/core/MoonModule.h
+++ b/src/core/MoonModule.h
@@ -278,9 +278,15 @@ class MoonModule {
uint8_t readUint8(const char* name, uint8_t dflt) const {
for (uint8_t i = 0; i < controls_.count(); i++) {
const ControlDescriptor& c = controls_[i];
- // Uint8 covers both a plain uint8 (brightness) and a Select stored as uint8 (palette).
+ // All three types back onto a `uint8_t*`: Uint8 (brightness), Select (a dropdown stored as
+ // an option index), and Palette (a palette-picker stored as a builtins-array index). The
+ // Palette omission here silently failed driversPalette()/similar callers to dflt=0,
+ // which then made HttpServerModule's WLED shim report seg[0].col as Rainbow's
+ // representative colour (white) regardless of the actually-picked palette — the "the HA
+ // wheel snaps back to the centre and the card renders white" bug pinned on the bench.
if (c.ptr && std::strcmp(c.name, name) == 0 &&
- (c.type == ControlType::Uint8 || c.type == ControlType::Select))
+ (c.type == ControlType::Uint8 || c.type == ControlType::Select ||
+ c.type == ControlType::Palette))
return *static_cast(c.ptr);
}
return dflt;
diff --git a/src/core/MqttModule.cpp b/src/core/MqttModule.cpp
index 3475feff..8cb77279 100644
--- a/src/core/MqttModule.cpp
+++ b/src/core/MqttModule.cpp
@@ -4,6 +4,9 @@
#include "core/JsonUtil.h" // json::hasKey/parseBool/parseInt/parseString — the inbound ha/set parse
#include "core/JsonSink.h" // jsonEscape — escape the editable deviceName into the discovery JSON
// (same flat helpers HttpServerModule::applyWledState uses; no arena)
+#include "core/build_info.h" // kVersion / kFirmwareName — reported to HA's update entity
+#include "core/FirmwareUpdateModule.h" // g_otaStatus / g_otaBytesTotal / otaInFlight — shared with
+ // the OTA task the update entity's install command triggers
#include "light/Palette.h" // Palettes::nearestForHue — a pure hue/sat→index CONVERSION with no
// light state or objects, the one narrow reach this core module makes
// into the light domain. PO-accepted: routing a HomeKit colour to a
@@ -107,8 +110,13 @@ void MqttModule::publishDiscovery(bool announce) {
char topic[96];
buildDiscoveryTopic(topic, sizeof(topic));
- // Identity + display name. uniq_id/object_id derive from the stable MAC; name is the friendly
- // deviceName (a rename updates the label, never the id — see the topic-identity decision).
+ // Identity + display name. uniq_id/object_id derive from the stable MAC; the friendly deviceName
+ // rides only on `dev.name`. The entity's own `name` is null — HA's documented convention "this
+ // entity IS the device, no sub-label" — so the auto-created entity slug is `light.` rather
+ // than the doubled `light._` HA produces when the light and the device carry the
+ // same name string. Rename the device → `dev.name` follows on the next discovery publish, uniq_id
+ // stays MAC-pinned, the slug (locked at creation) is unchanged. Documented at
+ // https://www.home-assistant.io/integrations/mqtt/#name — `name: null` is the recommended way.
uint8_t mac[6] = {};
platform::getMacAddress(mac);
char id[24];
@@ -116,7 +124,7 @@ void MqttModule::publishDiscovery(bool announce) {
const char* dn = systemModule_ ? systemModule_->deviceName() : nullptr;
if (!dn || !dn[0]) dn = id;
// The deviceName is user-editable: a quote/backslash in it would produce invalid JSON. Escape it
- // once (both name fields use it) with the shared jsonEscape — worst case doubles the 32-char name.
+ // (used for dev.name only now) with the shared jsonEscape — worst case doubles the 32-char name.
char dnEsc[72];
jsonEscape(dn, dnEsc, sizeof(dnEsc));
@@ -127,11 +135,12 @@ void MqttModule::publishDiscovery(bool announce) {
// JSON-schema MQTT light. Abbreviated keys (HA's documented short forms). brightness at the
// default 0-255 scale (no scale key needed). dev{} groups the entity under a device card in HA.
+ // `name:null` (see comment above) collapses the entity slug so `light.` isn't doubled.
const int pn = std::snprintf(discoveryPayload_, kDiscoveryPayloadLen,
- "{\"schema\":\"json\",\"name\":\"%s\",\"uniq_id\":\"%s\",\"cmd_t\":\"%s\","
+ "{\"schema\":\"json\",\"name\":null,\"uniq_id\":\"%s\",\"cmd_t\":\"%s\","
"\"stat_t\":\"%s\",\"avty_t\":\"%s\",\"brightness\":true,"
"\"dev\":{\"ids\":[\"%s\"],\"name\":\"%s\",\"mf\":\"MoonModules\",\"mdl\":\"projectMM\"}}",
- dnEsc, id, cmd, stat, avty, id, dnEsc);
+ id, cmd, stat, avty, id, dnEsc);
if (pn <= 0 || static_cast(pn) >= kDiscoveryPayloadLen) return; // truncated → don't send a broken config
const size_t n = buildMqttPublish(topic, reinterpret_cast(discoveryPayload_),
@@ -155,6 +164,161 @@ void MqttModule::subscribeHaSet() {
if (n == 0 || !sendPacket(buf, n)) resetConnection("error: ha subscribe failed");
}
+// ----------------------------------------------------------------------------
+// HA update entity — the second HA-discovery component alongside the light. Same
+// announce/retract shape (both gated on haDiscovery_), same MAC-stable uniq_id,
+// same broker connection. The state block is written once at CONNACK and on
+// haDiscovery-on-mid-session; there is nothing per-tick to refresh yet because
+// installed_version and (for now) latest_version are compile-time constants.
+// ----------------------------------------------------------------------------
+
+// Mirror of buildDiscoveryTopic but for the `update` component type. Same object id
+// (`projectMM_`) so the update entity registers under the SAME HA device card
+// as the light — one device, two entities. Diverging the id would produce a second
+// device card in HA, which reads as "two projectMMs" and is the wrong grouping.
+void MqttModule::buildUpdateDiscoveryTopic(char* out, size_t cap) const {
+ uint8_t mac[6] = {};
+ platform::getMacAddress(mac);
+ std::snprintf(out, cap, "homeassistant/update/%s_%02x%02x%02x/config",
+ prefixRoot_, mac[3], mac[4], mac[5]);
+}
+
+// Announce/retract the update entity. The announcement payload is ~300 bytes (short id +
+// three topic paths + escaped deviceName), so the framed MQTT PUBLISH exceeds the on-stack
+// kSendBufLen (256). Reuses the same lazily-allocated discoveryBuf_/discoveryPayload_ pair
+// the light-discovery uses (448 + 320 bytes): the two announces run serially inside
+// publishDiscovery/publishUpdateDiscovery, never in flight simultaneously, so a shared
+// scratch is safe. Retract fits in the on-stack kSendBufLen because the payload is empty
+// (a tombstone is topic + zero-byte body).
+void MqttModule::publishUpdateDiscovery(bool announce) {
+ if (state_ != Conn::Connected) return;
+
+ char topic[96];
+ buildUpdateDiscoveryTopic(topic, sizeof(topic));
+
+ // Retract path: empty retained payload = HA removes the entity. Fits easily in
+ // kSendBufLen; deferred to the next CONNACK if we're offline (broker keeps the last
+ // retained config until then, same pattern as the light retract).
+ if (!announce) {
+ uint8_t tomb[kSendBufLen];
+ const size_t n = buildMqttPublish(topic, nullptr, 0, tomb, sizeof(tomb), /*retain=*/true);
+ if (n == 0 || !sendPacket(tomb, n)) resetConnection("error: update discovery retract failed");
+ return;
+ }
+
+ if (!ensureDiscoveryBuffers()) { setStatusLine("error: discovery alloc failed"); return; }
+
+ uint8_t mac[6] = {};
+ platform::getMacAddress(mac);
+ char id[24];
+ std::snprintf(id, sizeof(id), "%s_%02x%02x%02x", prefixRoot_, mac[3], mac[4], mac[5]);
+ const char* dn = systemModule_ ? systemModule_->deviceName() : nullptr;
+ if (!dn || !dn[0]) dn = id;
+ char dnEsc[72];
+ jsonEscape(dn, dnEsc, sizeof(dnEsc));
+
+ char stat[80], cmd[80], avty[80];
+ buildTopic(stat, sizeof(stat), "update/state");
+ buildTopic(cmd, sizeof(cmd), "update/set");
+ buildStatusTopic(avty, sizeof(avty));
+
+ // device_class:"firmware" is what makes HA render the friendly name as " Firmware"
+ // instead of the bare device name (which collides visually with the light entity in HA's
+ // entity list — the "two MM-P4 rows" bench symptom pinned this). It also picks the correct
+ // icon and the "up-to-date / update available" wording. entity_category:"diagnostic" parks
+ // it in HA's diagnostic section of the device card, matching how ESPHome and Tasmota surface
+ // firmware info. `name:null` still applies — with device_class set, HA composes the label
+ // itself (` Firmware`), which is exactly what we want.
+ const int pn = std::snprintf(discoveryPayload_, kDiscoveryPayloadLen,
+ "{\"name\":null,\"uniq_id\":\"%s_update\",\"stat_t\":\"%s\",\"cmd_t\":\"%s\","
+ "\"avty_t\":\"%s\",\"entity_category\":\"diagnostic\",\"device_class\":\"firmware\","
+ "\"dev\":{\"ids\":[\"%s\"],\"name\":\"%s\",\"mf\":\"MoonModules\",\"mdl\":\"projectMM\"}}",
+ id, stat, cmd, avty, id, dnEsc);
+ if (pn <= 0 || static_cast(pn) >= kDiscoveryPayloadLen) return; // truncated → don't send
+
+ const size_t n = buildMqttPublish(topic, reinterpret_cast(discoveryPayload_),
+ static_cast(pn), discoveryBuf_, kDiscoveryBufLen,
+ /*retain=*/true);
+ if (n == 0) { setStatusLine("error: update discovery too large"); return; }
+ if (!sendPacket(discoveryBuf_, n)) resetConnection("error: update discovery publish failed");
+}
+
+// Retained state on /update/state. installed_version = compile-time MM_VERSION;
+// latest_version equals it today (no release check on-device yet — see the backlog item
+// under "HA update entity via MQTT discovery"), so HA shows the entity as up-to-date and
+// disables the Install button. When the release-check component lands, it becomes the
+// caller of this method with a fresher latest_version; the wire shape doesn't change.
+// release_url is the GitHub releases page — HA renders it as a "Release notes" link.
+void MqttModule::publishUpdateState() {
+ if (state_ != Conn::Connected) return;
+ char topic[128];
+ buildTopic(topic, sizeof(topic), "update/state");
+ char payload[256];
+ const int pn = std::snprintf(payload, sizeof(payload),
+ "{\"installed_version\":\"%s\",\"latest_version\":\"%s\","
+ "\"release_url\":\"https://github.com/MoonModules/projectMM/releases\","
+ "\"title\":\"projectMM firmware\"}",
+ kVersion, kVersion);
+ if (pn <= 0 || static_cast(pn) >= sizeof(payload)) return;
+ uint8_t buf[kSendBufLen];
+ const size_t n = buildMqttPublish(topic, reinterpret_cast(payload),
+ static_cast(pn), buf, sizeof(buf), /*retain=*/true);
+ if (n == 0 || !sendPacket(buf, n)) resetConnection("error: update state publish failed");
+}
+
+void MqttModule::subscribeUpdateSet() {
+ if (state_ != Conn::Connected) return;
+ char topic[96];
+ buildTopic(topic, sizeof(topic), "update/set");
+ uint8_t buf[kSendBufLen];
+ const size_t n = buildMqttSubscribe(nextPacketId_++, topic, buf, sizeof(buf));
+ if (n == 0 || !sendPacket(buf, n)) resetConnection("error: update subscribe failed");
+}
+
+// HA's install command. The payload is the target version string (via HA's
+// payload_install_template — defaults to `{{ latest_version }}`); an empty payload
+// means "install latest". The device builds the download URL from the projectMM
+// release-artifact convention:
+// https://github.com/MoonModules/projectMM/releases/download/v/firmware--v.bin
+// and hands it to platform::http_fetch_to_ota — the same OTA path POST /api/firmware/url
+// takes. Guarded by otaInFlight() so a second install command mid-flash returns silently
+// rather than corrupting the running OTA task. On desktop platform::http_fetch_to_ota is
+// a stub returning false; the install command safely reports failure via g_otaStatus.
+void MqttModule::handleUpdateInstall(const char* payload, size_t payloadLen) {
+ if (otaInFlight()) return; // matches the /api/firmware/url 409 guard's intent
+
+ // Copy the payload into a bounded local buffer for null-termination + shape checks.
+ // A malformed / oversized payload is refused; no partial URL reaches http_fetch_to_ota.
+ char version[32] = {};
+ const size_t vlen = payloadLen < sizeof(version) - 1 ? payloadLen : sizeof(version) - 1;
+ std::memcpy(version, payload, vlen);
+ version[vlen] = '\0';
+ // Strip an optional leading 'v' — HA's payload_install_template can send either shape.
+ const char* v = (version[0] == 'v') ? version + 1 : version;
+ // Empty payload means "install latest" (HA's default payload_install_template is
+ // `{{ latest_version }}`, which a device with no known newer version renders empty).
+ // Fall back to this build's own version string, so an empty command re-installs the
+ // current release rather than silently doing nothing — as the header contract states.
+ if (v[0] == '\0') v = (kVersion[0] == 'v') ? kVersion + 1 : kVersion;
+
+ char url[256];
+ const int un = std::snprintf(url, sizeof(url),
+ "https://github.com/MoonModules/projectMM/releases/download/v%s/firmware-%s-v%s.bin",
+ v, kFirmwareName, v);
+ if (un <= 0 || static_cast(un) >= sizeof(url)) return;
+
+ // Seed the shared globals so the first WS push shows "starting" rather than a stale
+ // string from a prior URL-triggered OTA — same seed the HTTP path does.
+ std::snprintf(g_otaStatus, sizeof(g_otaStatus), "starting");
+ g_otaBytesRead = 0;
+ g_otaBytesTotal = 0;
+
+ (void)platform::http_fetch_to_ota(url, g_otaStatus, sizeof(g_otaStatus),
+ &g_otaBytesRead, &g_otaBytesTotal);
+ // No response to publish — HA polls the retained update/state (which the OTA success
+ // path implicitly renegotiates on reboot, or a future release-check refreshes).
+}
+
void MqttModule::setup() {
setStatusLine(enabled() ? "idle" : "disabled");
MoonModule::setup();
@@ -173,7 +337,7 @@ void MqttModule::onBuildControls() {
controls_.addUint16("port", port_, 1, 65535);
controls_.addText("username", username_, sizeof(username_));
controls_.addPassword("password", password_, sizeof(password_));
- controls_.addBool("haDiscovery", haDiscovery_); // announce a HA-discovery light (default on)
+ controls_.addBool("haDiscovery", haDiscovery_); // announce a HA MQTT-discovery light (default off; WLED /json covers HA)
controls_.addReadOnly("mqtt_status", statusStr_, sizeof(statusStr_));
MoonModule::onBuildControls();
}
@@ -190,8 +354,14 @@ void MqttModule::onUpdate(const char* controlName) {
// Announce or retract live — NO reset (bouncing the socket to change a discovery flag is
// needless). On a turn-ON mid-session also SUBSCRIBE (subscriptions otherwise only fire at
// CONNACK); on turn-OFF the retract clears the HA entity. Publishes only when connected.
+ // Same announce/retract shape for the update entity — one gate, two components on the same
+ // device card.
publishDiscovery(haDiscovery_);
- if (haDiscovery_) { subscribeHaSet(); publishState(true); }
+ publishUpdateDiscovery(haDiscovery_);
+ if (haDiscovery_) {
+ subscribeHaSet(); publishState(true);
+ subscribeUpdateSet(); publishUpdateState();
+ }
}
}
@@ -412,9 +582,17 @@ void MqttModule::handleInboundByte(uint8_t byte) {
}
// On connect: announce + subscribe when discovery is on; when it's OFF, retract instead — a
// config retained from a previous session (discovery was on, then turned off while offline)
- // would otherwise keep HA's entity alive across this reconnect.
- if (haDiscovery_) { publishDiscovery(true); subscribeHaSet(); }
- else { publishDiscovery(false); }
+ // would otherwise keep HA's entity alive across this reconnect. The update entity mirrors
+ // this — one gate, both components announced/retracted together, so HA either sees both or
+ // neither (never a device card with a light but a dangling stale update entity).
+ if (haDiscovery_) {
+ publishDiscovery(true); subscribeHaSet();
+ publishUpdateDiscovery(true); subscribeUpdateSet();
+ publishUpdateState();
+ } else {
+ publishDiscovery(false);
+ publishUpdateDiscovery(false);
+ }
publishState(true); // publish initial state so mqttthing + HA show it
} else if (type == static_cast(MqttPacketType::Publish)) {
const char* topic = nullptr; const uint8_t* payload = nullptr; size_t plLen = 0;
@@ -432,6 +610,16 @@ void MqttModule::routePublish(const char* topic, const uint8_t* payload, size_t
if (std::strncmp(topic, prefix, prefixLen) != 0 || topic[prefixLen] != '/') return;
const char* suffix = topic + prefixLen + 1;
+ // HA update-entity install command — the payload is the target version string (HA's
+ // payload_install_template default is `{{ latest_version }}`, an empty payload means
+ // "install latest"). Routed to handleUpdateInstall which builds the GitHub-release URL and
+ // hands off to the same platform::http_fetch_to_ota the /api/firmware/url route uses.
+ // Checked BEFORE ha/set so the shared prefix parse fires exactly once.
+ if (std::strcmp(suffix, "update/set") == 0) {
+ handleUpdateInstall(reinterpret_cast(payload), payloadLen);
+ return;
+ }
+
// HA-native JSON command: {"state":"ON"|"OFF"[,"brightness":0-255]}. Parsed with the same flat
// mm::json helpers HttpServerModule::applyWledState uses (key-order-independent, whitespace-safe);
// needs a bigger NUL-terminated buffer than the scalar `value[32]` below. HA brightness is already
diff --git a/src/core/MqttModule.h b/src/core/MqttModule.h
index d4f88f84..7557bc23 100644
--- a/src/core/MqttModule.h
+++ b/src/core/MqttModule.h
@@ -32,11 +32,14 @@ namespace mm {
/// `/hsv/set` ← "h,s,v" → hue+sat → nearest palette → Drivers.palette
/// `/hsv/get` → publish the chosen palette's representative "h,s,v"
///
-/// **Home Assistant MQTT Discovery** (the `haDiscovery` control, default on). On connect the device
-/// publishes a RETAINED JSON-schema light config to `homeassistant/light/projectMM_/config`, so
-/// HA (and any Discovery-aware hub — the Tasmota/ESPHome/Zigbee2MQTT convention) **auto-creates a
-/// wired light entity** — no hand-matching topics. It then speaks HA's own schema alongside the
-/// mqttthing topics above:
+/// **Home Assistant MQTT Discovery** (the `haDiscovery` control, default off / opt-in). When on, the
+/// device publishes a RETAINED JSON-schema light config to `homeassistant/light/projectMM_/config`,
+/// so HA (and any Discovery-aware hub — the Tasmota/ESPHome/Zigbee2MQTT convention) **auto-creates a
+/// wired light entity** — no hand-matching topics. It defaults off because the WLED-compat surface (the
+/// HttpServerModule `/json` shim) already gives HA a richer light — colour, palette, sensors — over
+/// mDNS with no broker, so a device on defaults appears in HA once, not twice; MQTT discovery is the
+/// opt-in for broker-only / cross-subnet setups where mDNS doesn't reach. When on it speaks HA's own
+/// schema alongside the mqttthing topics above:
/// `/ha/set` ← `{"state":"ON"|"OFF"[,"brightness":0-255]}` → Drivers.on/brightness
/// `/ha/state` → retained `{"state":…,"brightness":0-255}` on change (HA-scale, no rescale)
/// `/status` → retained "online"; the CONNECT **Last-Will** publishes "offline" here
@@ -46,6 +49,16 @@ namespace mm {
/// JSON (not the default schema): a control maps to a config key rather than a topic, so HA's native
/// `effect`/`effect_list` renders a picker on the one command topic — the reason JSON is chosen here.
///
+/// The same discovery-gate also publishes a second HA component — an **update entity** — on
+/// `homeassistant/update/projectMM_/config`. HA renders it as a *"Firmware: "* card
+/// in the device panel with an Install button; state comes from `/update/state`, install
+/// commands from `/update/set`. `installed_version` = build-time `MM_VERSION`; `latest_version`
+/// equals it today (no on-device release check yet, so HA shows "up-to-date"), and the install path
+/// routes to `platform::http_fetch_to_ota`. Prevents HA's WLED integration from mis-flagging a WLED
+/// firmware update against a projectMM device — the `/json` `info.ver` reports our semver so WLED's
+/// comparison is always-newer, and this update entity is where a real projectMM update surfaces once
+/// the release-check component lands.
+///
/// **Lifecycle** (all on loop1s(), off the render hot path — MQTT is slow control): connect lazily
/// once `networkReady() && enabled`, CONNECT → CONNACK → SUBSCRIBE to the `set` topics, PINGREQ every
/// keepalive/2, drain inbound bytes through MqttInboundParser and route PUBLISHes to Drivers, and
@@ -110,6 +123,22 @@ class MqttModule : public MoonModule {
void publishDiscovery(bool announce); // announce=false publishes an empty retained config (retract)
void subscribeHaSet(); // SUBSCRIBE to /ha/set (at CONNACK + on live toggle-on)
+ // HA update entity — a second HA-discovery component in the same shape as the light above, on
+ // `homeassistant/update/projectMM_/config`. Renders as a first-class *"Firmware: "*
+ // card in the device panel with an Install button; state comes from `/update/state`,
+ // install commands from `/update/set` route to `platform::http_fetch_to_ota`. A companion
+ // to the light discovery: same haDiscovery gate, same announce/retract pair, no per-tick state
+ // (`installed_version` is a compile-time constant). `latest_version` equals `installed_version`
+ // today (no on-device release check yet — see the "HA update entity via MQTT discovery" backlog
+ // item), so HA renders "up-to-date" and the Install button stays disabled until a release-check
+ // component fills in a newer `latest_version`. Wiring the install path now sets the shape for
+ // that follow-up rather than leaving a half-built discovery config in the tree.
+ void buildUpdateDiscoveryTopic(char* out, size_t cap) const;
+ void publishUpdateDiscovery(bool announce);
+ void publishUpdateState(); // retained JSON on /update/state
+ void subscribeUpdateSet(); // SUBSCRIBE to /update/set (install command)
+ void handleUpdateInstall(const char* payload, size_t payloadLen);
+
SystemModule* systemModule_ = nullptr;
// The topic prefix is DERIVED from a STABLE hardware id: projectMM/. Not stored (no
@@ -126,7 +155,7 @@ class MqttModule : public MoonModule {
uint16_t port_ = 1883;
char username_[48] = "";
char password_[48] = "";
- bool haDiscovery_ = true; // announce a HA MQTT-discovery light (opt-out); see publishDiscovery
+ bool haDiscovery_ = false; // announce a HA MQTT-discovery light (opt-in); see publishDiscovery
char statusStr_[64] = "disabled";
platform::TcpConnection conn_;
diff --git a/src/core/moonlive/moonlive_emit.h b/src/core/moonlive/moonlive_emit.h
index 78c8a28d..ce0c1309 100644
--- a/src/core/moonlive/moonlive_emit.h
+++ b/src/core/moonlive/moonlive_emit.h
@@ -3,6 +3,12 @@
#include
#include
+// MM_MOONLIVE_HAS_HOST_JIT is defined in platform_config.h (per-platform), so the
+// arch check stays behind the platform boundary and this header stays neutral. The macro
+// (not a constexpr) is required because #include-side test files gate whole TEST_CASEs
+// on `#if MM_MOONLIVE_HAS_HOST_JIT`.
+#include "platform/platform.h"
+
// MoonLive — per-ISA code emitter (the backend seam, §3.2 of livescripts-analysis-top-down.md).
//
// This header is the NEUTRAL declaration the engine calls; the implementation is per-ISA and
diff --git a/src/light/Palette.h b/src/light/Palette.h
index ae11bf42..f2eb17fc 100644
--- a/src/light/Palette.h
+++ b/src/light/Palette.h
@@ -250,6 +250,34 @@ class Palettes {
return hue;
}
+ // Representative RGB colour of built-in `index` — the palette's identity colour, useful anywhere
+ // an external surface (HA WLED /json seg[0].col, an MQTT hsv/get with an RGB-shaped payload,
+ // a future HomeKit RGB accessory) needs one RGB triple to name "the colour of this palette".
+ // Full V=255 in HSV, so the reported hue survives external dimming applied on top (HA's slider
+ // multiplies segment.bri × state.bri, so baking in brightness here would double-dim).
+ // Rainbow / grey palettes (sat=0) intentionally resolve to white (255,255,255) — the honest
+ // representation of a multi-hue gradient's identity. A degenerate index or a future palette that
+ // averages to true zero would fall out as (0,0,0); no fallback here — callers decide policy.
+ static RGB representativeRgb(uint8_t index) {
+ uint16_t hue = 0, sat = 0;
+ representativeHueSat(index, hue, sat);
+ // hue 0..359 → h 0..255 for hsvToRgb's 6-sector integer map (Palette.h convention: hue units
+ // are 0..359 externally; core/color.h uses 0..255 internally).
+ return hsvToRgb(static_cast((hue * 256u) / 360u),
+ static_cast(sat), 255);
+ }
+
+ // RGB → nearest palette, the one call every RGB-input consumer (HA's WLED colour picker via
+ // /json/state, an ESP-NOW / REST colour message, a future BLE-mesh command …) should reach for
+ // instead of open-coding the RGB→HSV conversion. Converts to (hue, sat) with the same maths
+ // representativeHueSat uses on the palette side — so the input and the palette centroids are
+ // measured on the same axes — then delegates to nearestForHue for the 2D distance sweep.
+ static uint8_t nearestForRgb(uint8_t r, uint8_t g, uint8_t b) {
+ uint16_t hue = 0, sat = 0;
+ rgbToHueSat(r, g, b, hue, sat);
+ return nearestForHue(hue, static_cast(sat));
+ }
+
// The palette whose representative (hue, sat) is closest to the target, by 2D distance in
// (circular-hue, saturation) space. `hue` 0..359, `sat` 0..255. Low target saturation snaps to a
// desaturated palette (e.g. Rainbow); a vivid hue snaps to the matching single-hue palette.
@@ -322,4 +350,13 @@ inline void paletteOptions(JsonSink& sink) {
}
}
+// Emit the built-in palette names as a bare JSON string array (`"Default","Rainbow",…`), the
+// element list WLED's /json `palettes` array carries — HA's WLED integration renders one dropdown
+// entry per name, indexed by position (so seg[0].pal picks into this list). Same source of truth as
+// paletteOptions (kBuiltins), just names without the colour swatches the native UI needs.
+inline void paletteNames(JsonSink& sink) {
+ for (uint8_t i = 0; i < palettes::kCount; i++)
+ sink.appendf("%s\"%s\"", i > 0 ? "," : "", palettes::kBuiltins[i].name);
+}
+
} // namespace mm
diff --git a/src/light/drivers/Drivers.h b/src/light/drivers/Drivers.h
index 1af626b8..094f708c 100644
--- a/src/light/drivers/Drivers.h
+++ b/src/light/drivers/Drivers.h
@@ -8,6 +8,7 @@
#include "light/layers/BlendMap.h"
#include "light/drivers/Correction.h"
#include "light/Palette.h" // the global active palette + its select control
+#include "core/LightSummary.h" // the POD published for the domain-neutral WLED/MQTT consumers
#include "platform/platform.h"
#include // std::strcmp in onUpdate
@@ -70,6 +71,24 @@ class Drivers : public MoonModule {
public:
const char* acceptsChildRoles() const override { return "driver"; }
+ /// The live light-pipeline summary (light count, channels), for the domain-neutral core
+ /// consumers (WLED /json shim, MQTT). Static so a factory-created consumer reaches it without
+ /// a wiring inject — the same shape as `AudioModule::latestFrame()`. Points at the active
+ /// Drivers' summary (set in onBuildState, vacated in teardown); a default all-zero summary
+ /// when no Drivers is in the tree, so a consumer always reads a valid POD, never null.
+ static const LightSummary* latestSummary() {
+ static const LightSummary kNone{};
+ return active_ ? &active_->summary_ : &kNone;
+ }
+
+ // Vacate the summary seat when this Drivers is removed, so latestSummary() falls back to the
+ // all-zero default rather than a dangling pointer (the robustness rule). MoonModule::teardown
+ // recurses to children.
+ void teardown() override {
+ if (active_ == this) active_ = nullptr;
+ MoonModule::teardown();
+ }
+
/// Global brightness (0–255). Scales every channel through a 256-entry LUT
/// (`(v × brightness) / 255`); changing it rebuilds only the LUT on the cheap
/// `onUpdate` tier — no pipeline realloc, so the slider is fluent. Gamma /
@@ -198,6 +217,12 @@ class Drivers : public MoonModule {
outputBuffer_.free();
}
setDynamicBytes(outputBuffer_.bytes());
+ // Publish the light-pipeline summary for the domain-neutral core consumers (the WLED
+ // /json shim, MQTT) via the static latestSummary() pull. `out` is the composite extent;
+ // no enabled layer → zero lights. One POD, overwritten in place on each rebuild.
+ summary_.lightCount = out ? static_cast(out->physicalLightCount()) : 0;
+ summary_.channelsPerLight = out ? out->channelsPerLight() : 3;
+ active_ = this;
passBufferToDrivers();
MoonModule::onBuildState();
}
@@ -261,6 +286,12 @@ class Drivers : public MoonModule {
Buffer outputBuffer_;
Correction correction_;
+ // Published to core via latestSummary(). active_ points at the Drivers whose summary is live
+ // (mirrors AudioModule::active_): set in onBuildState, cleared in teardown so a removed Drivers
+ // stops being read; only one Drivers exists in the pinned tree, so no re-election dance is needed.
+ LightSummary summary_;
+ inline static Drivers* active_ = nullptr;
+
void passBufferToDrivers() {
// No active Layer (e.g. the last Layer was just deleted): clear every
// driver's Layer + source-buffer pointers rather than leaving them at
diff --git a/src/light/effects/AudioSpectrumEffect.h b/src/light/effects/AudioSpectrumEffect.h
index 9e0c13ba..73285b8f 100644
--- a/src/light/effects/AudioSpectrumEffect.h
+++ b/src/light/effects/AudioSpectrumEffect.h
@@ -30,7 +30,9 @@ class AudioSpectrumEffect : public EffectBase {
// 0 = height gradient (green base → red top, the VU look); 1 = per-band hue
// (each column its own colour across the spectrum, the rainbow analyser look).
- uint8_t colorMode = 0;
+ // Default per-band: the rainbow analyser reads as a spectrum at a glance, which is
+ // what a "spectrum" effect is expected to look like.
+ uint8_t colorMode = 1;
void onBuildControls() override {
static constexpr const char* kColorOptions[] = {"height", "per-band"};
diff --git a/src/platform/desktop/platform_config.h b/src/platform/desktop/platform_config.h
index 9484937a..ab79af3e 100644
--- a/src/platform/desktop/platform_config.h
+++ b/src/platform/desktop/platform_config.h
@@ -76,3 +76,17 @@ constexpr bool hasOta = false;
constexpr bool hasImprov = false;
} // namespace mm::platform
+
+// MM_MOONLIVE_HAS_HOST_JIT — 1 when this desktop host has BOTH the emit blob AND the general
+// assembler (moonlive_asm_host.cpp) available. Both are arm64-only today; x86_64 Windows/Linux/
+// Intel-macOS desktops ship without a backend and MoonLive::compile / compileSource fail
+// cleanly (scripted modules render dark). Tests and scenarios that presuppose a working JIT
+// gate on this macro. Kept in platform_config.h (not core) per the platform-boundary rule:
+// no `#if defined(__aarch64__)` outside src/platform/. A #define (not constexpr) so #include-
+// side test files can use it in `#if` — CLAUDE.md's `if constexpr` preference is for runtime
+// branches inside code, not preprocessor gating around whole TEST_CASEs.
+#if defined(__aarch64__)
+ #define MM_MOONLIVE_HAS_HOST_JIT 1
+#else
+ #define MM_MOONLIVE_HAS_HOST_JIT 0
+#endif
diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp
index 5abab6e6..0af2bbd7 100644
--- a/src/platform/desktop/platform_desktop.cpp
+++ b/src/platform/desktop/platform_desktop.cpp
@@ -512,6 +512,8 @@ void wifiStaGetIPv4(uint8_t out[4]) { out[0] = out[1] = out[2] = out[3] = 0; }
void setHostname(const char* /*name*/) {} // no DHCP client on desktop
void wifiStaStop() {}
int wifiStaRssi() { return 0; }
+void wifiStaBssid(uint8_t out[6]) { std::memset(out, 0, 6); }
+int wifiStaChannel() { return 0; }
bool wifiApInit(const char* /*apName*/, const char* /*ip*/) { return false; }
bool wifiApConnected() { return false; }
@@ -744,9 +746,19 @@ bool UdpSocket::sendTo(const uint8_t* data, size_t len) {
bool UdpSocket::bind(uint16_t port) {
if (fd_ < 0) return false;
+ // SO_REUSEADDR semantic split: on POSIX it lets a fresh socket claim a port left in
+ // TIME_WAIT (never allows two live binds to overlap). On Winsock its meaning is the
+ // opposite of POSIX — two live sockets can bind the same port, so a second bind()
+ // returns success instead of the EADDRINUSE the audio-sync retry-backoff logic reads
+ // as "port owned by someone else" (unit_AudioModule_sync's hog-then-module scenario
+ // exercises exactly that). Windows' equivalent-to-POSIX behaviour is the *default*,
+ // so on Windows we skip the setsockopt and let a second bind fail naturally. Same
+ // observable outcome on both platforms: overlapping binds are refused.
+#ifndef _WIN32
int reuse = 1;
::setsockopt(sock(fd_), SOL_SOCKET, SO_REUSEADDR,
reinterpret_cast(&reuse), sizeof(reuse));
+#endif
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h
index e9e9ad91..df9c2b18 100644
--- a/src/platform/esp32/platform_config.h
+++ b/src/platform/esp32/platform_config.h
@@ -270,3 +270,9 @@ constexpr bool hasOta = true;
constexpr bool hasImprov = true;
} // namespace mm::platform
+
+// MM_MOONLIVE_HAS_HOST_JIT — always 0 on ESP32. The unit tests that consume this macro run on
+// the *desktop* host binary; on-device MoonLive uses its own per-ISA backends (Xtensa / RISC-V
+// in src/platform/esp32/moonlive_emit.cpp), validated by the live hardware run, not host tests.
+// Kept in platform_config.h so the core header stays free of architecture #ifs.
+#define MM_MOONLIVE_HAS_HOST_JIT 0
diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp
index 5f306e82..f0aad4fe 100644
--- a/src/platform/esp32/platform_esp32.cpp
+++ b/src/platform/esp32/platform_esp32.cpp
@@ -929,6 +929,20 @@ int wifiStaRssi() {
return info.rssi;
}
+void wifiStaBssid(uint8_t out[6]) {
+ std::memset(out, 0, 6);
+ if (!wifiStaConnected_) return;
+ wifi_ap_record_t info{};
+ if (esp_wifi_sta_get_ap_info(&info) == ESP_OK) std::memcpy(out, info.bssid, 6);
+}
+
+int wifiStaChannel() {
+ if (!wifiStaConnected_) return 0;
+ wifi_ap_record_t info{};
+ if (esp_wifi_sta_get_ap_info(&info) != ESP_OK) return 0;
+ return info.primary;
+}
+
bool wifiApInit(const char* apName, const char* ip) {
// Guard against repeated init leaking the previous AP netif.
// Stop before ensureWifiInit() — wifiApStop() deinits the WiFi driver.
@@ -1037,6 +1051,8 @@ bool wifiStaConnected() { return false; }
void wifiStaGetIPv4(uint8_t out[4]) { out[0] = out[1] = out[2] = out[3] = 0; }
void wifiStaStop() {}
int wifiStaRssi() { return 0; }
+void wifiStaBssid(uint8_t out[6]) { std::memset(out, 0, 6); }
+int wifiStaChannel() { return 0; }
bool wifiApInit(const char* /*apName*/, const char* /*ip*/) { return false; }
bool wifiApConnected() { return false; }
void wifiApStop() {}
@@ -1084,6 +1100,26 @@ bool mdnsInit(const char* deviceName) {
return false;
}
+ // Explicitly register + enable the Ethernet netif with mDNS. The component "runs by
+ // default on preconfigured interfaces (STA, AP, ETH)", but on the ESP32-P4 that
+ // auto-attach does NOT catch the eth netif, so the SRV/A record ships with no address
+ // and the device advertises a _wled._tcp / _http._tcp service HA can see but not
+ // resolve (blank IP in HA's Zeroconf browser, so no auto-discovery). Registering the
+ // netif by pointer + MDNS_EVENT_ENABLE_IP4 forces the probe→announce onto the real
+ // interface. Idempotent on the targets where the predef ETH already covers it:
+ // mdns_register_netif returns ESP_ERR_INVALID_STATE ("already registered"), which we
+ // treat as success, so this one path fixes the P4 without regressing S31/classic/S3.
+ if (ethNetif_ && ethConnected()) {
+ esp_err_t regErr = mdns_register_netif(ethNetif_);
+ if (regErr == ESP_OK || regErr == ESP_ERR_INVALID_STATE) {
+ esp_err_t actErr = mdns_netif_action(ethNetif_, MDNS_EVENT_ENABLE_IP4);
+ ESP_LOGI(NET_TAG, "mDNS eth netif register:%s enable:%s",
+ regErr == ESP_OK ? "new" : "already", esp_err_to_name(actErr));
+ } else {
+ ESP_LOGW(NET_TAG, "mDNS eth netif register failed: %s", esp_err_to_name(regErr));
+ }
+ }
+
// FORCE A FRESH RE-ADVERTISE: remove any existing service record, then add it back.
// A reconnect / interface switch / live rename re-runs this; just renaming the
// instance (mdns_service_instance_name_set) does NOT reliably re-announce on the
diff --git a/src/platform/platform.h b/src/platform/platform.h
index 627a79ab..02f19904 100644
--- a/src/platform/platform.h
+++ b/src/platform/platform.h
@@ -159,6 +159,12 @@ void wifiStaStop();
// while state_ == ConnectedSta so a 0 is effectively unreachable.
int wifiStaRssi();
+// STA-side AP info for the WLED /json `wifi` block: the associated AP's BSSID
+// (6 octets into `out`) and the WiFi channel. Both zeroed (all-zero BSSID /
+// channel 0) when the STA isn't associated or the call fails.
+void wifiStaBssid(uint8_t out[6]);
+int wifiStaChannel();
+
bool wifiApInit(const char* apName, const char* ip);
bool wifiApConnected();
void wifiApStop();
diff --git a/test/python/test_installer_manifests.py b/test/python/test_installer_manifests.py
index 099ae151..d6673017 100644
--- a/test/python/test_installer_manifests.py
+++ b/test/python/test_installer_manifests.py
@@ -52,7 +52,12 @@
def _shipping_firmwares():
- data = json.loads(FIRMWARES_JSON.read_text())
+ # encoding='utf-8' — the JSON carries a fs/documentation-note character
+ # (byte 0x8f is part of a UTF-8 multi-byte sequence for a non-ASCII glyph
+ # in one of the comment fields) and Windows Python 3.14 defaults read_text
+ # to cp1252, which fails with UnicodeDecodeError. Same class of Windows-
+ # only encoding bug as verify_version's stdout crash.
+ data = json.loads(FIRMWARES_JSON.read_text(encoding="utf-8"))
return [f["name"] for f in data["firmwares"] if f.get("ships")]
@@ -72,7 +77,7 @@ def _generate(firmware, tmp_path):
)
assert res.returncode == 0, f"generate_manifest failed for {firmware}: {res.stdout}{res.stderr}"
assert out.exists(), f"no manifest written for {firmware}"
- return json.loads(out.read_text())
+ return json.loads(out.read_text(encoding="utf-8"))
def test_every_shipping_firmware_has_a_manifest_with_parts(tmp_path):
diff --git a/test/python/test_wled_json_shape.py b/test/python/test_wled_json_shape.py
new file mode 100644
index 00000000..5305e16d
--- /dev/null
+++ b/test/python/test_wled_json_shape.py
@@ -0,0 +1,99 @@
+# /// script
+# dependencies = ["pytest", "wled"]
+# ///
+"""HA WLED integration `/json` contract test.
+
+Pins the shape HttpServerModule::serveWledDeviceJson emits, by parsing a golden
+capture through frenck/python-wled's real dataclass models — the exact library
+Home Assistant's WLED integration uses. If HA's WLED library changes what it
+requires (a new mandatory field, a stricter validator), or if serveWledDeviceJson
+drops a field the library needs, this test fails locally BEFORE HA fails on the
+live device — the parse either raises or leaves a required attribute missing.
+
+The golden vector below is a byte-for-byte capture of `curl /json` from the
+desktop build, so a regression in the C++ writer is caught as a raw-string diff
+on this constant; the `Device.from_dict` assertion then re-confirms python-wled
+still accepts the shape. Refresh the golden by running the desktop and pasting
+the new /json body here — the point of the vector is that a change is a
+deliberate acknowledgement, not a silent drift.
+"""
+
+import json
+
+from wled.models import Device
+
+
+# Captured from `curl http://127.0.0.1:8080/json` against a Windows desktop
+# build (2026-07-07). MAC/IP are the desktop stub's synthetic values, but every
+# WLED-shim field python-wled reads is present.
+GOLDEN_JSON = (
+ '{"state":{"on":true,"bri":20,"transition":7,"ps":-1,"pl":-1,"nl":{},"udpn":{},"lor":0,"mainseg":0,'
+ '"seg":[{"id":0,"on":true,"bri":255,"col":[[255,255,255]]}]},'
+ # ver is the sentinel "99.0.0", NOT the projectMM semver. Reason: HA's WLED integration
+ # parses WLED tags as CalVer (`16.0.1` is year-16, not `0.16.1`), so a projectMM semver
+ # like `2.1.0-dev` compares LOWER (2 < 16) and HA nagged "update to WLED 16.0.1" on the
+ # bench P4 even after the flash. `99.0.0` beats every WLED tag in the CalVer regime; the
+ # real projectMM version lives on the MQTT update entity's `installed_version`.
+ '"info":{"ver":"99.0.0","vid":2410150,"name":"MM-CAFE","mac":"deadbeefcafe","ip":"192.168.1.246",'
+ '"arch":"esp32","brand":"WLED","product":"MoonModules","release":"MoonModules",'
+ '"leds":{"count":1,"fps":60,"rgbw":false,"wv":false,"cct":false,"maxpwr":0,"maxseg":1,"pwr":0,"lc":1,"seglc":[1]},'
+ '"wifi":{"bssid":"00:00:00:00:00:00","rssi":-50,"channel":0,"signal":100},'
+ '"fs":{"t":256,"u":32,"pmt":1},"freeheap":100000,"uptime":42,"udpport":21324,"live":false,'
+ '"lm":"","lip":"","ws":-1,"fxcount":1,"palcount":1,"cpalcount":0,"umpalcount":0,"str":false},'
+ '"effects":["Solid"],"palettes":["Default"]}'
+)
+
+
+def test_golden_parses_as_json():
+ d = json.loads(GOLDEN_JSON)
+ assert set(d.keys()) == {"state", "info", "effects", "palettes"}
+
+
+def test_python_wled_accepts_device():
+ """The canonical assertion: python-wled's Device.from_dict must not raise.
+
+ This runs the same __pre_deserialize__ (version gate, effects/palettes
+ normalisation) and post-deserialize hooks (arch lowercasing, websocket
+ None-fill) HA's WLED integration runs on every state refresh.
+ """
+ d = json.loads(GOLDEN_JSON)
+ dev = Device.from_dict(d)
+
+ # Info — the required `fs` object populates; `ver` clears the version gate;
+ # brand/product/mac/arch reach HA's device card.
+ assert dev.info.brand == "WLED"
+ assert dev.info.product == "MoonModules"
+ assert dev.info.architecture == "esp32" # post_deserialize lowercases
+ assert dev.info.mac_address == "deadbeefcafe"
+ assert dev.info.filesystem.total == 256
+ assert dev.info.filesystem.used == 32
+
+ # State — nl / udpn / lor are the three non-defaulted State fields that,
+ # missing, would raise. Empty {} for nl/udpn is intentional: dataclass
+ # defaults are correct for a device that doesn't implement nightlight or
+ # WLED-native UDP sync.
+ assert dev.state.on is True
+ assert dev.state.brightness == 20
+ assert dev.state.nightlight.on is False # default
+ assert dev.state.sync.receive is False # default (udpn: {})
+ assert int(dev.state.live_data_override) == 0 # LiveDataOverride.OFF
+
+ # Effects + palettes — one each is enough for HA to render pickers.
+ assert dev.effects and len(dev.effects) >= 1
+ assert dev.palettes and len(dev.palettes) >= 1
+
+
+def test_all_python_wled_required_fields_present():
+ """The compile-time check python-wled gives us: every field WITHOUT a
+ dataclass default MUST be present in the JSON, or the parse raises. Guard
+ against a future python-wled release adding a new required field going
+ unnoticed until an HA user's config flow crashes."""
+ d = json.loads(GOLDEN_JSON)
+ # Info.filesystem is the ONLY Info field without a default; State.nl / udpn
+ # / lor are the three required State fields. This test doubles as an audit:
+ # if the assertion set below stops matching the model at some point, the
+ # writer needs the new field too.
+ assert "fs" in d["info"], "python-wled Info.filesystem has no default"
+ assert "nl" in d["state"], "python-wled State.nightlight has no default"
+ assert "udpn" in d["state"], "python-wled State.sync has no default"
+ assert "lor" in d["state"], "python-wled State.live_data_override has no default"
diff --git a/test/scenario_runner.cpp b/test/scenario_runner.cpp
index 724572d1..957c17b0 100644
--- a/test/scenario_runner.cpp
+++ b/test/scenario_runner.cpp
@@ -397,6 +397,24 @@ static int runScenario(const char* path) {
std::printf("%s\n", scenario["description"].c_str());
std::printf("Target: %s\n\n", hostTarget());
+ // Honour a scenario-level `skip_on` allowlist of host targets that lack
+ // a capability the scenario exercises (today: MoonLive scenarios opt out
+ // on pc-windows / pc-linux — the desktop JIT is arm64-only, so an x86_64
+ // host renders dark and the scenario's "buffer non-zero" check would fail
+ // for a platform-capability reason it isn't the right vehicle to assert).
+ // Absent / empty `skip_on` runs everywhere (the existing default). Same
+ // field the Python run_scenario.py honours; keeping the C++ runner in
+ // step so KPI collection (which calls mm_scenarios directly) doesn't
+ // count skipped scenarios as failures.
+ if (scenario.has("skip_on")) {
+ for (auto& t : scenario["skip_on"].arr) {
+ if (t.str == hostTarget()) {
+ std::printf(" SKIP (skip_on %s)\n---\nPASSED (skipped)\n", hostTarget());
+ return 0;
+ }
+ }
+ }
+
// Mode field (construct/mutate) determines what shape the scenario expects
// the world to be in. See docs/testing.md § Scenario modes.
// construct → scenario builds the pipeline from an empty scheduler; runs
diff --git a/test/scenarios/core/scenario_MoonModule_control_change.json b/test/scenarios/core/scenario_MoonModule_control_change.json
index c4c1d50b..828600f9 100644
--- a/test/scenarios/core/scenario_MoonModule_control_change.json
+++ b/test/scenarios/core/scenario_MoonModule_control_change.json
@@ -190,7 +190,7 @@
"pc-windows": {
"tick_us": [
227,
- 250
+ 383
],
"free_heap": [
0,
@@ -202,7 +202,7 @@
],
"at": [
"2026-06-07",
- "2026-06-07"
+ "2026-07-08"
]
},
"esp32p4-eth": {
@@ -322,8 +322,8 @@
},
"pc-windows": {
"tick_us": [
- 418,
- 494
+ 278,
+ 2500
],
"free_heap": [
0,
@@ -335,7 +335,7 @@
],
"at": [
"2026-06-07",
- "2026-06-07"
+ "2026-07-08"
]
},
"esp32p4-eth": {
@@ -456,7 +456,7 @@
"pc-windows": {
"tick_us": [
766,
- 924
+ 3398
],
"free_heap": [
0,
@@ -468,7 +468,7 @@
],
"at": [
"2026-06-07",
- "2026-06-07"
+ "2026-07-08"
]
},
"esp32p4-eth": {
@@ -597,7 +597,7 @@
"pc-windows": {
"tick_us": [
206,
- 246
+ 3399
],
"free_heap": [
0,
@@ -609,7 +609,7 @@
],
"at": [
"2026-06-07",
- "2026-06-07"
+ "2026-07-08"
]
},
"esp32p4-eth": {
diff --git a/test/scenarios/light/scenario_Audio_mutation.json b/test/scenarios/light/scenario_Audio_mutation.json
index b5cd094d..910b7125 100644
--- a/test/scenarios/light/scenario_Audio_mutation.json
+++ b/test/scenarios/light/scenario_Audio_mutation.json
@@ -102,6 +102,24 @@
"2026-06-12",
"2026-07-05"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 36,
+ 50
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -141,6 +159,24 @@
"2026-06-12",
"2026-07-05"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 36,
+ 53
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -196,6 +232,24 @@
"2026-06-13",
"2026-07-01"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 36,
+ 50
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -235,6 +289,24 @@
"2026-06-12",
"2026-07-01"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 40,
+ 58
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -272,6 +344,24 @@
"2026-06-12",
"2026-07-01"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 40,
+ 58
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -309,6 +399,24 @@
"2026-06-12",
"2026-07-01"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 35,
+ 70
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
}
diff --git a/test/scenarios/light/scenario_Driver_mutation.json b/test/scenarios/light/scenario_Driver_mutation.json
index 1d034d83..c0816b41 100644
--- a/test/scenarios/light/scenario_Driver_mutation.json
+++ b/test/scenarios/light/scenario_Driver_mutation.json
@@ -91,6 +91,24 @@
"2026-06-13",
"2026-06-30"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 36,
+ 51
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -130,6 +148,24 @@
"2026-06-13",
"2026-07-01"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 35,
+ 70
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -169,6 +205,24 @@
"2026-06-13",
"2026-07-01"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 43,
+ 84
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -206,6 +260,24 @@
"2026-06-13",
"2026-07-01"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 42,
+ 66
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -243,6 +315,24 @@
"2026-06-13",
"2026-07-01"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 36,
+ 56
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
}
diff --git a/test/scenarios/light/scenario_GridLayout_resize.json b/test/scenarios/light/scenario_GridLayout_resize.json
index b3f86804..ecd1ac17 100644
--- a/test/scenarios/light/scenario_GridLayout_resize.json
+++ b/test/scenarios/light/scenario_GridLayout_resize.json
@@ -190,7 +190,7 @@
"pc-windows": {
"tick_us": [
219,
- 293
+ 1242
],
"free_heap": [
0,
@@ -202,7 +202,7 @@
],
"at": [
"2026-06-07",
- "2026-06-07"
+ "2026-07-08"
]
},
"esp32p4-eth": {
@@ -341,7 +341,7 @@
"pc-windows": {
"tick_us": [
94,
- 137
+ 164
],
"free_heap": [
0,
@@ -353,7 +353,7 @@
],
"at": [
"2026-06-07",
- "2026-06-07"
+ "2026-07-08"
]
},
"esp32p4-eth": {
@@ -492,7 +492,7 @@
"pc-windows": {
"tick_us": [
217,
- 291
+ 2544
],
"free_heap": [
0,
@@ -504,7 +504,7 @@
],
"at": [
"2026-06-07",
- "2026-06-07"
+ "2026-07-08"
]
},
"esp32p4-eth": {
diff --git a/test/scenarios/light/scenario_Layer_base_pipeline.json b/test/scenarios/light/scenario_Layer_base_pipeline.json
index aa75836f..ce8fa744 100644
--- a/test/scenarios/light/scenario_Layer_base_pipeline.json
+++ b/test/scenarios/light/scenario_Layer_base_pipeline.json
@@ -102,7 +102,7 @@
"pc-windows": {
"tick_us": [
118,
- 127
+ 936
],
"free_heap": [
0,
@@ -114,7 +114,7 @@
],
"at": [
"2026-06-07",
- "2026-06-07"
+ "2026-07-08"
]
}
}
diff --git a/test/scenarios/light/scenario_Layer_memory_1to1.json b/test/scenarios/light/scenario_Layer_memory_1to1.json
index 3b9b6ec3..adde8636 100644
--- a/test/scenarios/light/scenario_Layer_memory_1to1.json
+++ b/test/scenarios/light/scenario_Layer_memory_1to1.json
@@ -99,7 +99,7 @@
"pc-windows": {
"tick_us": [
1,
- 2
+ 7
],
"free_heap": [
0,
@@ -111,7 +111,7 @@
],
"at": [
"2026-06-07",
- "2026-06-07"
+ "2026-07-08"
]
}
}
diff --git a/test/scenarios/light/scenario_Layers_composition.json b/test/scenarios/light/scenario_Layers_composition.json
index 1477d15f..3dc3e862 100644
--- a/test/scenarios/light/scenario_Layers_composition.json
+++ b/test/scenarios/light/scenario_Layers_composition.json
@@ -121,6 +121,24 @@
"2026-06-25",
"2026-07-01"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 549,
+ 3747
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
}
diff --git a/test/scenarios/light/scenario_Layouts_mutation.json b/test/scenarios/light/scenario_Layouts_mutation.json
index 1ca1a552..5698b4cb 100644
--- a/test/scenarios/light/scenario_Layouts_mutation.json
+++ b/test/scenarios/light/scenario_Layouts_mutation.json
@@ -97,7 +97,7 @@
"pc-windows": {
"tick_us": [
27,
- 31
+ 60
],
"free_heap": [
0,
@@ -109,7 +109,7 @@
],
"at": [
"2026-06-07",
- "2026-06-07"
+ "2026-07-08"
]
},
"esp32-eth": {
@@ -176,7 +176,7 @@
"pc-windows": {
"tick_us": [
42,
- 61
+ 185
],
"free_heap": [
0,
@@ -188,7 +188,7 @@
],
"at": [
"2026-06-07",
- "2026-06-07"
+ "2026-07-08"
]
},
"esp32-eth": {
@@ -250,7 +250,7 @@
"pc-windows": {
"tick_us": [
111,
- 171
+ 532
],
"free_heap": [
0,
@@ -262,7 +262,7 @@
],
"at": [
"2026-06-07",
- "2026-06-07"
+ "2026-07-08"
]
},
"esp32-eth": {
@@ -323,7 +323,7 @@
"pc-windows": {
"tick_us": [
26,
- 30
+ 184
],
"free_heap": [
0,
@@ -335,7 +335,7 @@
],
"at": [
"2026-06-07",
- "2026-06-07"
+ "2026-07-08"
]
},
"esp32-eth": {
diff --git a/test/scenarios/light/scenario_MoonLiveEffect_controls.json b/test/scenarios/light/scenario_MoonLiveEffect_controls.json
index 0c4c0017..7a4f7daf 100644
--- a/test/scenarios/light/scenario_MoonLiveEffect_controls.json
+++ b/test/scenarios/light/scenario_MoonLiveEffect_controls.json
@@ -2,6 +2,7 @@
"name": "scenario_MoonLiveEffect_controls",
"module": "MoonLiveEffect",
"mode": "mutate",
+ "skip_on": ["pc-windows", "pc-linux"],
"also": [
"Layouts",
"GridLayout",
diff --git a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json
index 201599b9..e69e7caa 100644
--- a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json
+++ b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json
@@ -2,6 +2,7 @@
"name": "scenario_MoonLiveEffect_livescript",
"module": "MoonLiveEffect",
"mode": "mutate",
+ "skip_on": ["pc-windows", "pc-linux"],
"also": [
"Layouts",
"GridLayout",
diff --git a/test/scenarios/light/scenario_MultiplyModifier_pipeline.json b/test/scenarios/light/scenario_MultiplyModifier_pipeline.json
index 16314db1..79be1264 100644
--- a/test/scenarios/light/scenario_MultiplyModifier_pipeline.json
+++ b/test/scenarios/light/scenario_MultiplyModifier_pipeline.json
@@ -108,7 +108,7 @@
"pc-windows": {
"tick_us": [
225,
- 253
+ 445
],
"free_heap": [
0,
@@ -120,7 +120,7 @@
],
"at": [
"2026-06-07",
- "2026-06-07"
+ "2026-07-08"
]
}
}
diff --git a/test/scenarios/light/scenario_modifier_chain.json b/test/scenarios/light/scenario_modifier_chain.json
index 814f9ca1..6611a352 100644
--- a/test/scenarios/light/scenario_modifier_chain.json
+++ b/test/scenarios/light/scenario_modifier_chain.json
@@ -116,6 +116,24 @@
"2026-06-26",
"2026-06-30"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 13,
+ 25
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -145,6 +163,24 @@
"2026-06-26",
"2026-07-01"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 12,
+ 20
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -172,6 +208,24 @@
"2026-06-26",
"2026-07-01"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 42,
+ 58
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -201,6 +255,24 @@
"2026-06-26",
"2026-07-01"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 69,
+ 138
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
}
diff --git a/test/scenarios/light/scenario_modifier_swap.json b/test/scenarios/light/scenario_modifier_swap.json
index 6b7b123f..4069ccd1 100644
--- a/test/scenarios/light/scenario_modifier_swap.json
+++ b/test/scenarios/light/scenario_modifier_swap.json
@@ -238,6 +238,24 @@
"2026-06-25",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 14,
+ 20
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -342,6 +360,24 @@
"2026-06-25",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 41,
+ 61
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -446,6 +482,24 @@
"2026-06-25",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 13,
+ 22
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
}
diff --git a/test/scenarios/light/scenario_perf_full.json b/test/scenarios/light/scenario_perf_full.json
index 6b81aa2f..b0999865 100644
--- a/test/scenarios/light/scenario_perf_full.json
+++ b/test/scenarios/light/scenario_perf_full.json
@@ -155,6 +155,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 3,
+ 5
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -244,6 +262,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 3,
+ 5
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -333,6 +369,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 3,
+ 4
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -420,6 +474,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 1,
+ 1
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -514,6 +586,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 3,
+ 5
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -601,6 +691,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 3,
+ 9
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -699,6 +807,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 3,
+ 4
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -798,6 +924,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 3,
+ 5
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -897,6 +1041,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 3,
+ 12
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -1006,6 +1168,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 3,
+ 5
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -1099,6 +1279,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 12,
+ 127
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -1192,6 +1390,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 49,
+ 87
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -1285,6 +1501,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 196,
+ 276
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -1386,6 +1620,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 9,
+ 13
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -1479,6 +1731,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 36,
+ 52
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -1572,6 +1842,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 145,
+ 260
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -1665,6 +1953,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 592,
+ 851
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -1766,6 +2072,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 2,
+ 5
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -1859,6 +2183,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 9,
+ 16
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -1952,6 +2294,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 36,
+ 55
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -2045,6 +2405,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 145,
+ 1010
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
}
diff --git a/test/scenarios/light/scenario_perf_light.json b/test/scenarios/light/scenario_perf_light.json
index 991a523a..af8e4a66 100644
--- a/test/scenarios/light/scenario_perf_light.json
+++ b/test/scenarios/light/scenario_perf_light.json
@@ -170,6 +170,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 3,
+ 6
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -258,6 +276,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 1,
+ 2
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -338,6 +374,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 1,
+ 1
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -425,6 +479,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 2,
+ 3
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -518,6 +590,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 9,
+ 16
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
},
@@ -611,6 +701,24 @@
"2026-06-17",
"2026-06-25"
]
+ },
+ "pc-windows": {
+ "tick_us": [
+ 35,
+ 46
+ ],
+ "free_heap": [
+ 0,
+ 0
+ ],
+ "max_alloc_block": [
+ 0,
+ 0
+ ],
+ "at": [
+ "2026-07-08",
+ "2026-07-08"
+ ]
}
}
}
diff --git a/test/unit/core/unit_MqttModule.cpp b/test/unit/core/unit_MqttModule.cpp
index 13b0257e..8cef2541 100644
--- a/test/unit/core/unit_MqttModule.cpp
+++ b/test/unit/core/unit_MqttModule.cpp
@@ -194,6 +194,9 @@ TEST_CASE("MqttModule: CONNACK publishes a retained HA discovery config") {
Rig r;
uint8_t cap[1024];
r.mqtt->enableSendCaptureForTest(cap, sizeof(cap));
+ // haDiscovery defaults OFF (opt-in; the WLED /json shim covers HA), so enable it first — this
+ // test asserts the announce shape when the user opts into MQTT discovery.
+ Scheduler::instance()->setControl("Mqtt", "haDiscovery", "{\"value\":true}");
// A CONNACK-accept: fixed header 0x20, len 2, session-present 0, return-code 0 (accepted).
const uint8_t connack[] = {0x20, 0x02, 0x00, 0x00};
r.mqtt->feedForTest(connack, sizeof(connack));
@@ -227,6 +230,8 @@ TEST_CASE("MqttModule: retract frees the discovery buffers even while disconnect
Rig r;
uint8_t cap[1024];
r.mqtt->enableSendCaptureForTest(cap, sizeof(cap));
+ // haDiscovery defaults OFF; opt in so CONNACK allocates the discovery buffers this test tracks.
+ Scheduler::instance()->setControl("Mqtt", "haDiscovery", "{\"value\":true}");
// CONNACK-accept → Connected → announce allocates the discovery buffers (448 + 320 = 768).
const uint8_t connack[] = {0x20, 0x02, 0x00, 0x00};
r.mqtt->feedForTest(connack, sizeof(connack));
diff --git a/test/unit/core/unit_moonlive_compiler.cpp b/test/unit/core/unit_moonlive_compiler.cpp
index 924bd285..76c18ab4 100644
--- a/test/unit/core/unit_moonlive_compiler.cpp
+++ b/test/unit/core/unit_moonlive_compiler.cpp
@@ -3,6 +3,7 @@
#include "doctest.h"
#include "core/moonlive/MoonLiveCompiler.h"
#include "core/moonlive/MoonLive.h"
+#include "core/moonlive/moonlive_emit.h" // MM_MOONLIVE_HAS_HOST_JIT — the assembler+emit gate
#include "light/moonlive/MoonLiveBuiltins_light.h"
#include
@@ -21,6 +22,9 @@ using namespace mm;
static moonlive::BuiltinTable kTable = moonlive::lightBuiltins();
// Compile + run a source on a w-light, 3-channel buffer; returns the rendered buffer.
+// Only used by the JIT-gated tests below; guard the definition too so a non-JIT build
+// doesn't fire /W4's "unreferenced static function" (which -Werror escalates).
+#if MM_MOONLIVE_HAS_HOST_JIT
static std::vector render(const char* src, int nLights, uint32_t t = 0) {
moonlive::MoonLive eng;
REQUIRE(eng.compile(src, kTable));
@@ -29,7 +33,15 @@ static std::vector render(const char* src, int nLights, uint32_t t = 0)
eng.run(buf.data(), nLights, 3, t);
return buf;
}
-
+#endif
+
+// The compile-through-run tests need a working host JIT — the assembler (moonlive_asm_host.cpp)
+// is arm64-only today, so on x86_64 desktops compileSource returns !ok ("codegen failed") and
+// every "should compile" assertion fails. Guarded on the emit-header capability macro so they
+// compile out where the backend is unimplemented — the same "runs dark" degradation on-device.
+// The malformed-input tests further down don't gate: they assert failure, which succeeds for
+// the right reason (parse rejects) on arm64 and for a compatible reason (no codegen) on x86_64.
+#if MM_MOONLIVE_HAS_HOST_JIT
TEST_CASE("compileSource: fill(r,g,b) fills every light") {
auto buf = render("fill(10, 20, 200);", 8);
for (int i = 0; i < 8; i++) { CHECK(buf[i*3]==10); CHECK(buf[i*3+1]==20); CHECK(buf[i*3+2]==200); }
@@ -94,6 +106,7 @@ TEST_CASE("compileSource rejects malformed programs with a diagnostic, never cra
// A value-returning function used as a void statement IS valid (result discarded).
CHECK(moonlive::compileSource("random16(8);", kTable, out, sizeof(out)).ok);
}
+#endif // MM_MOONLIVE_HAS_HOST_JIT
TEST_CASE("MoonLive.compile(source) on a bad script leaves the engine !ok with an error") {
moonlive::MoonLive eng;
@@ -108,6 +121,7 @@ TEST_CASE("MoonLive.compile(source) on a bad script leaves the engine !ok with a
// VREG REUSE: a chain of calls must fit the small device register file. Each argument temp dies
// once its call consumes it and is recycled, so peak register pressure stays low no matter how
// many calls a statement nests — setRGB with all four arguments a random16 still compiles.
+#if MM_MOONLIVE_HAS_HOST_JIT
TEST_CASE("a multi-call statement reuses dead vregs and stays within the register budget") {
moonlive::BuiltinTable t = moonlive::lightBuiltins();
uint8_t out[768];
@@ -155,6 +169,7 @@ TEST_CASE("MoonLive recompiling swaps the program live (fill <-> setRGB)") {
// STAGE 1 CONTROLS — parse layer: a `uint8_t name = def; // @control min..max` declaration
// surfaces a DeclaredControl, and a declared name used in a statement resolves to it.
+// The DeclaredControl tests also need lowerToBytes to return non-zero — r.ok gates on it.
TEST_CASE("compileSource: a control declaration surfaces a DeclaredControl") {
uint8_t out[768];
auto r = moonlive::compileSource(
@@ -184,6 +199,7 @@ TEST_CASE("compileSource: a control declaration surfaces a DeclaredControl") {
REQUIRE(r3.controlCount == 1);
CHECK(r3.controls[0].min == 0); CHECK(r3.controls[0].max == 255); CHECK(r3.controls[0].def == 9);
}
+#endif // MM_MOONLIVE_HAS_HOST_JIT
TEST_CASE("compileSource: malformed control declarations fail with a diagnostic, never crash") {
uint8_t out[768];
diff --git a/test/unit/core/unit_moonlive_fill.cpp b/test/unit/core/unit_moonlive_fill.cpp
index 66742bf5..4ee4f11a 100644
--- a/test/unit/core/unit_moonlive_fill.cpp
+++ b/test/unit/core/unit_moonlive_fill.cpp
@@ -17,12 +17,14 @@
using namespace mm;
+#if MM_MOONLIVE_HAS_HOST_JIT
TEST_CASE("MoonLive emitFill produces a non-empty routine") {
uint8_t code[256];
size_t n = moonlive::emitFill(code, sizeof(code), 1, 2, 3);
CHECK(n > 0);
CHECK(n <= sizeof(code));
}
+#endif
TEST_CASE("MoonLive emitFill rejects a too-small buffer (degrades, no overrun)") {
uint8_t tiny[2];
@@ -34,6 +36,11 @@ TEST_CASE("MoonLive emitFill/emitAnimatedFill reject a null output buffer (no cr
CHECK(moonlive::emitAnimatedFill(nullptr, 256) == 0);
}
+// The compile-through-call tests below need a working host JIT (emit blob + assembler); on
+// x86_64 desktops (Windows, Linux/macOS Intel) MoonLive::compile fails cleanly and the tests
+// would fail on the REQUIRE. Guarded on the emit-header capability macro so they compile
+// out where the backend is unimplemented — the same "runs dark" degradation on-device.
+#if MM_MOONLIVE_HAS_HOST_JIT
TEST_CASE("MoonLive compiles and fills a buffer with the chosen colour") {
moonlive::MoonLive engine;
REQUIRE(engine.compile(/*r*/ 10, /*g*/ 20, /*b*/ 200));
@@ -127,6 +134,10 @@ TEST_CASE("platform allocExec returns usable executable memory, freeExec release
CHECK(buf[0] == 7);
platform::freeExec(blk, 64);
}
+#endif // MM_MOONLIVE_HAS_HOST_JIT — the JIT-dependent block ends here; the STAGE 1 CONTROLS below
+ // exercise the parser/arena, which also depend on compile() succeeding, so they gate too.
+
+#if MM_MOONLIVE_HAS_HOST_JIT
// STAGE 1 CONTROLS — engine-level arena behaviour. These pin the load-bearing decision: the
// control-values arena is owned by the engine, has a STABLE address across a recompile (grows
@@ -180,3 +191,4 @@ TEST_CASE("MoonLive controls: free() releases the arena (no stale slot after tea
REQUIRE(eng.controlSlot(0) != nullptr);
CHECK(*eng.controlSlot(0) == 5); // re-seeded from default
}
+#endif // MM_MOONLIVE_HAS_HOST_JIT
diff --git a/test/unit/core/unit_moonlive_ir.cpp b/test/unit/core/unit_moonlive_ir.cpp
index 15087cd3..f12309fd 100644
--- a/test/unit/core/unit_moonlive_ir.cpp
+++ b/test/unit/core/unit_moonlive_ir.cpp
@@ -33,6 +33,10 @@ FillFn place(const uint8_t* code, size_t n, void*& blkOut, size_t cap = 256) {
}
}
+// Every compile-through-run test in this file needs a working host JIT. The tiny-buffer
+// degrade test lower down (which asserts !ok) is left unguarded — it passes for the right
+// reason on arm64 (bytes exceed cap) and a compatible reason on x86_64 (no codegen).
+#if MM_MOONLIVE_HAS_HOST_JIT
TEST_CASE("MoonLive compiled fill is BEHAVIORALLY identical to the hand-encoded emitFill (golden)") {
const uint8_t cases[][3] = {{0, 0, 255}, {10, 20, 200}, {255, 255, 255}, {0, 0, 0}, {1, 2, 3}};
for (auto& c : cases) {
@@ -78,6 +82,7 @@ TEST_CASE("MoonLive compiled fill is robust: zero lights writes nothing") {
CHECK(buf[0] == 0xAB);
platform::freeExec(blk, 256);
}
+#endif // MM_MOONLIVE_HAS_HOST_JIT
TEST_CASE("MoonLive compileSource degrades on a too-small code buffer") {
uint8_t tiny[4];
@@ -85,6 +90,7 @@ TEST_CASE("MoonLive compileSource degrades on a too-small code buffer") {
CHECK_FALSE(moonlive::compileSource("fill(0,0,255);", kT, nullptr, 0).ok);
}
+#if MM_MOONLIVE_HAS_HOST_JIT
TEST_CASE("MoonLive compiled setRGB writes one pixel; out-of-range is bounds-rejected") {
uint8_t code[256];
// in-range
@@ -163,3 +169,4 @@ TEST_CASE("MoonLive control survives a host call (kArg4 live across random16)")
CHECK(lit == 1);
platform::freeExec(blk, r.len);
}
+#endif // MM_MOONLIVE_HAS_HOST_JIT
diff --git a/web-installer/deviceModels.json b/web-installer/deviceModels.json
index 8af9676e..e54b0399 100644
--- a/web-installer/deviceModels.json
+++ b/web-installer/deviceModels.json
@@ -488,7 +488,7 @@
"firmwares": [
"esp32s3-n16r8"
],
- "image": "assets/deviceModels/esp32-s3-n16r8-dev.jpg",
+ "image": "assets/deviceModels/esp32-s3-n16r8-dev.png",
"supported": [
"LEDs",
"WiFi"
@@ -524,7 +524,7 @@
"firmwares": [
"esp32s3-n16r8"
],
- "image": "assets/deviceModels/esp32-s3-n16r8-dev.jpg",
+ "image": "assets/deviceModels/esp32-s3-n16r8-dev.png",
"supported": [
"LEDs",
"WiFi",
@@ -547,9 +547,9 @@
"id": "Audio",
"parent_id": "System",
"controls": {
- "wsPin": 4,
- "sdPin": 5,
- "sckPin": 6
+ "sckPin": 47,
+ "wsPin": 48,
+ "sdPin": 21
}
},
{
@@ -557,7 +557,7 @@
"id": "OnboardLed",
"parent_id": "Drivers",
"controls": {
- "pins": "48",
+ "pins": "38",
"start": 0,
"count": 1
}
@@ -917,9 +917,9 @@
"id": "Audio",
"parent_id": "System",
"controls": {
+ "sckPin": 32,
"wsPin": 26,
"sdPin": 33,
- "sckPin": 32,
"mclkPin": 36,
"gain": 100
}
@@ -1010,9 +1010,9 @@
"id": "Audio",
"parent_id": "System",
"controls": {
+ "sckPin": 53,
"wsPin": 55,
- "sdPin": 54,
- "sckPin": 53
+ "sdPin": 54
}
},
{