diff --git a/README.md b/README.md index ddc6dc6c..96eb1a9f 100644 --- a/README.md +++ b/README.md @@ -152,10 +152,20 @@ This is the current iteration of years of LED / light system development. Each p | **WLED** | Open-source LED firmware (user / contributor since 2021) | [Aircoookie/WLED](https://github.com/Aircoookie/WLED) | | **WLED-MoonModules** | WLED fork with advanced features | [MoonModules/WLED](https://github.com/MoonModules/WLED) | | **StarLight** | Standalone LED firmware | [ewowi/StarLight](https://github.com/ewowi/StarLight) | -| **MoonLight** | Ground-up build: 60+ effects, memory-optimised mapping, 11 driver types | [MoonModules/MoonLight](https://github.com/MoonModules/MoonLight) | +| **MoonLight** | Ground-up build: 60+ effects, memory-optimised mapping, 11 driver types | [ewowi/MoonLight](https://github.com/ewowi/MoonLight) | We built, maintained, and contributed to these projects, so projectMM is grounded in years of our own hands-on experience, not arms-length study. Their lessons and proven patterns are distilled in [`docs/history/`](docs/history/README.md), alongside monthly digests of friend projects (like FastLED and upstream WLED) we follow closely but don't own. From all of it we carry the ideas forward into our own implementation: we apply what we learned and write our own code rather than copying theirs; and when a specific project or person inspires something here, we credit them by name (in the history digests and each module's "Prior art" notes). +## Credits + +Specific people whose work directly shaped parts of projectMM. We study their thinking with respect and write our own code against our architecture rather than tracing theirs — these credits name the prior art behind a feature: + +- **Frank ([softhack007](https://github.com/softhack007))** — main author of the WLED-MM audio-reactive usermod, the most-used open-source audio-reactive LED implementation. The ideas behind [AudioModule](docs/moonmodules/core/AudioModule.md) (including the adaptive noise-gate concept, analysed with his permission) descend from years of collaboration on WLED-SR / WLED-MM. +- **[troyhacks](https://github.com/troyhacks/WLED)** — reworked the WLED-MM audio-reactive DSP to run on Espressif's [esp-dsp](https://github.com/espressif/esp-dsp) FFT (a low-latency, "stupid fast" alternative to ArduinoFFT); the same esp-dsp FFT choice [AudioModule](docs/moonmodules/core/AudioModule.md) makes. See its Prior art notes. +- **[hpwit](https://github.com/hpwit) (Yves Bazin)** — the clockless I2S / RMT / Parlio LED-driver techniques and the [ESPLiveScript](https://github.com/hpwit/ESPLiveScript) live-script engine behind the LED drivers and MoonLive. +- **Christophe Gagnier ([@Moustachauve](https://github.com/Moustachauve))** — author of the native [WLED-Android](https://github.com/Moustachauve/WLED-Android) app. Its source let us reverse-engineer exactly what the WLED app reads, so projectMM devices appear in (and are controllable from) the native WLED apps. +- **The [Improv Wi-Fi](https://github.com/improv-wifi) project** — the open Improv serial provisioning standard ([sdk-cpp](https://github.com/improv-wifi/sdk-cpp) / [sdk-js](https://github.com/improv-wifi/sdk-js)) that the projectMM web installer uses to provision a freshly-flashed device over USB. + ## Contributing projectMM is a community project, built in the open, shaped by the people who use it. We'd love to hear from you: diff --git a/docs/architecture.md b/docs/architecture.md index e0edf390..cf101a8a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -467,7 +467,7 @@ All buffers are allocated as single contiguous blocks outside the hot path, at s - **Physical buffer**: when present, holds the blended+mapped output. It is a *blend* buffer, needed only for compositing (>1 layer, or any alpha/additive blend); it is not what provides producer/consumer parallelism. Under the 🚧 [two-core handover](#parallelism), parallelism comes from the consumer's own working copy, the encoded DMA buffer for a clockless LED driver, or the kernel socket buffer for ArtNet, which decouples the producer (filling the next Layer frame) from the consumer (transmitting the previous one). - **Mapping LUT**: flat lookup table for logical→physical. Read-only during rendering. PSRAM is fine: sequential reads are cache-friendly. -All buffers are raw `uint8_t*` arrays sized `channelsPerLight * nrOfLights`. There is no pre-allocated per-channel array and no fixed channel layout: `channelsPerLight` is a runtime value (a `uint8_t`, so 1–255), so RGB (3), RGBW (4), and multi-channel DMX fixtures all use the same code path; the buffer simply gets wider. Channel layout is configured via offsets (see MoonLight's [LightsHeader](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Layers/LightsHeader.h) pattern). +All buffers are raw `uint8_t*` arrays sized `channelsPerLight * nrOfLights`. There is no pre-allocated per-channel array and no fixed channel layout: `channelsPerLight` is a runtime value (a `uint8_t`, so 1–255), so RGB (3), RGBW (4), and multi-channel DMX fixtures all use the same code path; the buffer simply gets wider. Channel layout is configured via offsets (see MoonLight's [LightsHeader](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/LightsHeader.h) pattern). Network input (ArtNet receive, WebSocket) is processed synchronously at a defined point in the frame loop. Zero extra buffers, no race conditions. The trade-off is up to one frame of latency (~16 ms at 60 fps), imperceptible for LEDs. diff --git a/docs/assets/screenshots/Devices module.png b/docs/assets/screenshots/Devices module.png new file mode 100644 index 00000000..bc83e7dd Binary files /dev/null and b/docs/assets/screenshots/Devices module.png differ diff --git a/docs/assets/screenshots/WLED Native discovers projectMM.jpeg b/docs/assets/screenshots/WLED Native discovers projectMM.jpeg new file mode 100644 index 00000000..a907965b Binary files /dev/null and b/docs/assets/screenshots/WLED Native discovers projectMM.jpeg differ diff --git a/docs/assets/screenshots/Wled discovers projectMM.png b/docs/assets/screenshots/Wled discovers projectMM.png new file mode 100644 index 00000000..8e8aff01 Binary files /dev/null and b/docs/assets/screenshots/Wled discovers projectMM.png differ diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 0cb1a520..97b8d199 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -20,8 +20,8 @@ A map of everything in the three files, by theme. ### Core ([backlog-core.md](backlog-core.md)) -- **Distribution** — remaining platforms (Linux, Teensy, RPi), code-signing (macOS/Windows), live RMII Ethernet reconfigure, installer UX polish, P4 DHCP-hostname recheck, S31 web-flash (waiting on esptool-js); DevicesModule discovery growth (HTTP probe off the render task, more mDNS types + UDP, deterministic scan scenario). -- **ESP32 performance & memory** — E1.31 multicast (IGMP), WiFi ArtNet perf matrix, async ArtNet send (PSRAM-only), network round-trip drop/reorder test, slow eth bring-up, non-PSRAM memory ceiling + boot-time buffer degradation, task core-pinning; ops: static IP on STA, mDNS toggle, MoonDeck doc-asset hardening, CI SHA-pinning. +- **Distribution** — remaining platforms (Linux, Teensy, RPi), code-signing (macOS/Windows), live RMII Ethernet reconfigure, installer UX polish, P4 DHCP-hostname recheck, S31 web-flash (waiting on esptool-js); DevicesModule interop growth (more plugins, the command half, live peer state). +- **ESP32 performance & memory** — E1.31 multicast (IGMP), WiFi ArtNet perf matrix, async ArtNet send (PSRAM-only), network round-trip drop/reorder test, slow eth bring-up, non-PSRAM memory ceiling + boot-time buffer degradation, task core-pinning; ops: static IP on STA, MoonDeck doc-asset hardening, CI SHA-pinning. - **Architecture** — disable-releases-resources, cross-module pin-uniqueness check, Improv-child-of-NetworkModule, `std::span` platform API, Improv-as-REST follow-ups, **live scripting** (on-device authored effects/layouts/modifiers/drivers/sensor logic — design phase, see the bottom-up survey); composition/config: runtime board presets, per-layout coordinate offset. - **HTTP & OTA** — direct binary-upload OTA, HTTP file serving off the render tick. - **Testing** — additional coverage (UI load time, teardown memory, JS harness), live full-suite state leak. @@ -52,3 +52,7 @@ One-off research documents that informed a future direction, kept for the reason - [leddriver-analysis-bottom-up.md](leddriver-analysis-bottom-up.md) — the companion landscape survey: catalogues the existing LED-driver libraries across ESP32, Teensy, Raspberry Pi, and PC, and recommends a path. - [livescripts-analysis-bottom-up.md](livescripts-analysis-bottom-up.md) — live scripting (run user-authored effects/layouts/modifiers/drivers/sensor logic on-device without a reflash), Stage-1 survey. Deep-reads the ESPLiveScript fork (hpwit's native-Xtensa JIT), surveys the field (ARTI-FX interpreter by ewowi, embedded VMs, WASM/WAMR), and records the product-owner direction. - [livescripts-analysis-top-down.md](livescripts-analysis-top-down.md) — the Stage-2 redesign: a native-codegen engine, Xtensa-first behind an IR seam (WASM/WAMR the per-target fallback), a C-subset language that ports an effect near-verbatim, the MoonModule binding, and a staged spike plan along the MoonLight effects-tutorial ladder. + +## Project transition + +- [rename-to-moonlight.md](rename-to-moonlight.md) — the phased plan to rename **projectMM → MoonLight** (and move the predecessor MoonLight to a personal repo). Now / coming-time / during-the-switch sequencing around the repo-name collision, the externally-visible references that gate the cutover (binary name, OTA URLs, mDNS identity), and a MoSCoW of the feature gaps that must close before the new name isn't a downgrade. diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index 56f7b55f..48e63761 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -18,15 +18,19 @@ Forward-looking to-build items for the **core / infrastructure** domain (`src/co - **Windows code-signing** — drops the SmartScreen warning on first run of `projectMM.exe`. Same shape as macOS signing; needs an EV / OV code-signing certificate (Microsoft Trusted Signing is the cheapest current option). Until then, the README notes the SmartScreen prompt. - **Live RMII Ethernet reconfigure** — runtime PHY/pin config shipped (`ethType` + pin controls in NetworkModule, per-board defaults in `deviceModels.json`, `platform::setEthConfig`/`ethInit` dispatch). W5500 (SPI) on S3 applies **live** — `ethStop()` tears down the SPI bus and `ethInit()` re-runs on the next `loop1s()` with no reboot. RMII (classic/P4 internal EMAC) still saves config and asks for a restart to apply, because the EMAC bring-up is fiddlier to hot-cycle cleanly. Make RMII live too: a hot `esp_eth_stop` + EMAC/netif teardown + re-init on config change, matching the W5500 path, so every interface honours the no-reboot principle. - **Installer UX polish** — clear "Pre-release (beta)" warning on RC/latest picks, yank-by-asset-tag instead of yank-by-release-deletion. -- **ESP32-P4 DHCP hostname not shown by the router (recheck later)** — the device sets its DHCP hostname (option 12 = `deviceName`, default `MM-XXXX`) in the `ETHERNET_EVENT_CONNECTED` handler, verified working on two boards: the S3 over WiFi (router shows `MM-70BC`) and the Olimex over RMII Ethernet (`MM-BD3C`) — the *same* `ethEventHandler` code path the P4 uses. Yet the bench P4 (Waveshare P4-NANO, RMII) still shows as blank/"Unknown" in the GL.iNet client list, while serial confirms `set_hostname` succeeds with no error. Two unconfirmed suspects, neither our logic: (1) the router holds a **sticky lease** for the P4's MAC and won't relearn the hostname until it fully expires (the per-client "forget" isn't exposed in this GL.iNet UI, and a plain reboot didn't clear it); (2) a P4-specific IDF netif quirk serializing option 12 differently on the newer P4 Ethernet path. Since the shared code path is proven on two other boards, this is not treated as a code bug. Recheck after the P4's lease naturally expires, or on a different router, before spending more on it. **Possibly correlated:** the DevicesModule HTTP sweep also intermittently misses the P4 at `.132` (a single-pass probe timeout) while finding the S3 and PC reliably — both symptoms point at the P4 being slower/flakier to answer at the network layer (DHCP and/or TCP-accept latency on the P4 Ethernet path), not at our discovery or hostname logic. Investigate the P4's network responsiveness as the common cause. +- **Offer projectMM/MoonLight as a library** — a downstream sketch where another firmware/app consumes the light pipeline (or a subset) as an embeddable dependency rather than running the whole binary. `library.json` is already a PlatformIO *library* manifest, so the seed exists. When this is designed, give it a small public **identity surface**: one runtime constant the consumer reads (a `kProjectName`, likely a `ProjectInfo` bundle of name + version + url) that the network wire-strings (ArtNet/E1.31 source-name + CID), the UI banner, and any "About" string all *derive from* — the one place a consumer queries "what am I embedding." This is the genuine home for the name-centralisation that the rename ([rename-to-moonlight.md § Phase 1.3](rename-to-moonlight.md)) deliberately *didn't* do: the rename is a one-time sweep (a constant would just split it), but a library consumer references the identity ongoing and widely, which is the test a constant must pass. Build it *then*, against the real library API (per *Concrete first, abstract later*), not speculatively now. +- **ESP32-P4 DHCP hostname not shown by the router (recheck later)** — the device sets its DHCP hostname (option 12 = `deviceName`, default `MM-XXXX`) in the `ETHERNET_EVENT_CONNECTED` handler, verified working on two boards: the S3 over WiFi (router shows `MM-70BC`) and the Olimex over RMII Ethernet (`MM-BD3C`) — the *same* `ethEventHandler` code path the P4 uses. Yet the bench P4 (Waveshare P4-NANO, RMII) still shows as blank/"Unknown" in the GL.iNet client list, while serial confirms `set_hostname` succeeds with no error. Two unconfirmed suspects, neither our logic: (1) the router holds a **sticky lease** for the P4's MAC and won't relearn the hostname until it fully expires (the per-client "forget" isn't exposed in this GL.iNet UI, and a plain reboot didn't clear it); (2) a P4-specific IDF netif quirk serializing option 12 differently on the newer P4 Ethernet path. Since the shared code path is proven on two other boards, this is not treated as a code bug. Recheck after the P4's lease naturally expires, or on a different router, before spending more on it. -### DevicesModule — discovery growth (HTTP sweep + mDNS browse today) +### DevicesModule — interop plugins + the command half (discovery shipped) -DevicesModule discovers via two strategies that merge into one list: an **mDNS browse** every tick (non-blocking async query, cycles `_http._tcp` / `_wled._tcp`) and a **one-shot HTTP subnet sweep** at boot (plus a manual "scan" button). The sweep deliberately has **no periodic background re-run** because its probe is a *blocking* `httpGet` on the render task — each probe stalls the tick up to the probe timeout (~150 ms), which would flicker the LEDs if it ran continuously. Two improvements unlock faster, richer discovery: +DevicesModule discovers via **passive UDP presence** (UDP 65506) feeding a [`DevicePlugin`](../../src/core/DevicePlugin.h) seam (shipped: projectMM + WLED plugins). mDNS is advertise-only so projectMM appears in the native WLED apps + Home Assistant; the WLED-app interop (list + live colour + brightness control) is shipped too. What remains is *growth on the seam*, each piece additive (one plugin file, no core change): -- **Move the HTTP probe to its own FreeRTOS task** — run the blocking probes off the render task entirely, handing results back through a small thread-safe buffer. This is the enabler for both **speed** and **safe periodic** sweeping. The sweep is slow *by necessity* today: the probe blocks ~150 ms on the render task, so it can only do 1 IP/tick (a full /24 takes ~4 min) — bumping IPs/tick or shortening the timeout would either stall the tick (LED flicker) or miss slow responders. On its own task the probe can run many IPs concurrently with a generous timeout and **zero** hot-path impact, making a full sweep seconds instead of minutes AND making continuous background re-sweep safe. The boot-only + slow-sweep limitations both exist solely because the probe is on the render task today; this single change removes both. -- **More mDNS service types + UDP** — the mDNS browse cycle (`kMdnsServices`) extends one entry at a time as classification lands for each (Home Assistant `_home-assistant._tcp`, ESPHome `_esphome._tcp`, RTP-MIDI `_apple-midi._udp`). Separately, the **four-mechanism split** (decided): discovery and messaging are separate axes, none replaces another — **mDNS** = discovery (standard, whole ecosystem), **HTTP sweep** = discovery fallback (what mDNS misses, e.g. a PC instance on :8080), **REST `/api/control`** = *reliable* messaging (config push, fleet OTA — TCP guarantees delivery, already built), **UDP** = *lossy real-time streaming* only (SuperSync clock / live timing, where drop-and-continue is fine and low latency matters). The MoonLight "messages sometimes didn't arrive" pain came from using UDP for must-arrive messages — route must-arrive over REST, reserve UDP for streams. A UDP *presence beacon* could also seed projectMM↔projectMM discovery, but mDNS is preferred there as the recognizable standard. UDP receive is a cheap non-blocking poll; UDP *send* of large frames is throughput-bound (see Async ArtNet) and belongs off the render task. -- **Deterministic full-pipeline scan scenario (canned `httpGet`)** — `scenario_DevicesModule_scan.json` is live-only (needs a real LAN, runs on hardware). A desktop-runnable parallel that exercises scan → classify → upsert → age-out → list-serialization with *canned* `httpGet` responses would pin the whole discovery pipeline without flakiness. Needs a new platform seam: a settable response table the desktop `httpGet` consults (mirroring the existing `setTestNowMs` clock override). Today the age-out + restore + serialize paths are covered by `unit_DevicesModule_ageout.cpp` and classify by `unit_DeviceIdentify.cpp`, so this is breadth, not a gap — deferred so the httpGet-mock seam gets its own focused change rather than riding in on a review batch. +- **More discovery plugins** — ESPHome, Tasmota, Hue (*hub-shaped*: a bridge whose Zigbee bulbs are children behind it, with link-button auth). Each is a new `DevicePlugin` declaring its `discoveryPort()` + classifying the datagram (or, for a system that only does mDNS, a re-introduced advertise-side browse scoped to *foreign* services only — never the ones we advertise). Hue is the canonical "more than a flat device" case the seam is shaped for. +- **The command half** — `DevicePlugin::command()` (+ per-plugin capability/auth), so projectMM can *control* a discovered foreign device, not just list it: set WLED brightness via its JSON API, a Hue resource via the bridge's authenticated CLIP API, a Tasmota via `cmnd`. Built when a control consumer exists; the discovery seam is already shaped to accept it (incl. hub plugins). This is the **multi-ecosystem selling point** — one UI controlling WLED + ESPHome + Hue. Commands split by need (the rule, not "all REST"): must-arrive config over REST; latency-critical sync over UDP (~0.5–1 ms vs REST's 10–50 ms — REST would visibly de-sync). +- **Live peer state** — a discovered peer's brightness / on-off shown in our list, refreshed by polling its REST `/json` after discovery gives the IP (discovery = UDP/mDNS, state = REST). The read-side complement to the command half. +- **Non-IP transports (board-gated, far future)** — Tasmota-MQTT / zigbee2mqtt need an MQTT client; **direct Zigbee/Thread** (S31/C6/H2 802.15.4 radio) makes projectMM the *hub itself*, driving bulbs over the mesh with no gateway — the standout differentiator, the biggest lift. Same plugin philosophy, a transport addition + board gate. + +Full design + the reasoned transport split: [Plan-20260629 — UDP device discovery + mDNS advertise-only (shipped)](../history/plans/Plan-20260629%20-%20UDP%20device%20discovery%20%2B%20mDNS%20advertise-only%20%28shipped%29.md). ## ESP32 performance and memory @@ -105,10 +109,6 @@ Not blocking — MoonDeck is a developer tool, not a production server. Pick thi `.github/workflows/release.yml` references all 9 action types by mutable `@vN` tag (`actions/checkout@v4`, `astral-sh/setup-uv@v3`, `softprops/action-gh-release@v2`, `espressif/esp-idf-ci-action@v1`, …). A mutable tag can be force-moved to malicious code by a compromised publisher; pinning each `uses:` to a full commit SHA (with a `# vN` trailing comment) removes that vector. **Done already (cheaper half):** `persist-credentials: false` on every checkout that doesn't push, so the `GITHUB_TOKEN` isn't left in `.git/config` for later steps to read (the `release` job keeps it — it force-pushes the `latest` tag). **Not done (this item):** SHA-pinning, because it carries an ongoing cost — pinned SHAs go stale and miss security patches, so it only pays for itself **alongside Dependabot** (or a Renovate config) to auto-bump them. Pick this up as a deliberate "CI hardening + Dependabot" pass, not piecemeal. Low risk today: every action pinned is a first-party `actions/*` or a well-known publisher (astral, espressif, softprops), not an obscure third-party action. -### mDNS toggle (evaluate) - -Added as a diagnostic tool during performance investigation; testing showed mDNS has zero FPS impact. Evaluate whether to keep (useful for debugging on other boards) or remove (unnecessary complexity). Decide after WiFi performance testing above. - ### Static IP on WiFi STA — wire the existing fields to the network (backlog) NetworkModule exposes `addressing` (DHCP / Static) plus `ip` / `gateway` / `subnet` / `dns` fields, and they persist — but they are **not applied to the WiFi STA interface**. `wifiStaInit(ssid, password)` takes only credentials; the STA always runs DHCP (there is no `esp_netif_dhcpc_stop` + `esp_netif_set_ip_info` on `staNetif_` — that pattern exists only for the AP). So selecting Static and entering an IP currently does nothing: the device keeps its DHCP lease. The fields are display-only scaffolding ahead of the functionality. @@ -190,7 +190,7 @@ Board preset catalog + upload (later, when the runtime config has real consumers - Pin reassignment requires reboot (ESP-IDF can't hot-reconfigure EMAC pins after `esp_eth_driver_install`); document the constraint. - A first attempt at this catalog landed and was rolled back during the firmware-vs-board separation work — the catalog only earns its keep once the device reads it, otherwise it's a docs-shaped file in the wrong place. -**Prior art — MoonLight's per-board pin database** ([ModuleIO.h](https://github.com/MoonModules/MoonLight/blob/main/src/MoonBase/Modules/ModuleIO.h)). MoonLight (our own project) already models exactly this for ~25 boards across ESP32-D0 / S3 / P4: a `pins[]` array of `{GPIO, usage, index}` plus board-level `maxPower`, `ethernetType`, `ethPhyAddr`, `ethClkMode`. Don't copy the file or paste its tables here — read it when building the catalog and write our own. Its `usage` enum enumerates the hardware functionalities a projectMM board preset *could* drive once the device-side consumers exist (each needs its own module/control before the corresponding `deviceModels.json` / catalog field earns its keep — none exist today beyond `System.deviceModel` + `Network.txPowerSetting`): +**Prior art — MoonLight's per-board pin database** ([ModuleIO.h](https://github.com/ewowi/MoonLight/blob/main/src/MoonBase/Modules/ModuleIO.h)). MoonLight (our own project) already models exactly this for ~25 boards across ESP32-D0 / S3 / P4: a `pins[]` array of `{GPIO, usage, index}` plus board-level `maxPower`, `ethernetType`, `ethPhyAddr`, `ethClkMode`. Don't copy the file or paste its tables here — read it when building the catalog and write our own. Its `usage` enum enumerates the hardware functionalities a projectMM board preset *could* drive once the device-side consumers exist (each needs its own module/control before the corresponding `deviceModels.json` / catalog field earns its keep — none exist today beyond `System.deviceModel` + `Network.txPowerSetting`): - **LED output pins** — per-strip data GPIOs (1–16 outputs/board); the first real consumer (a Driver pin control) unblocks multi-output boards (QuinLED Dig-Quad/Octa, SE16, LightCrafter). - **Ethernet PHY config** — LAN8720/RMII (MDC/MDIO/CLK/power-pin/PHY-addr/clock-mode) vs W5500/SPI (MISO/MOSI/SCK/CS/IRQ); the consumer is the runtime `Network.eth_*` controls listed above, replacing the hardcoded Olimex pins. diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md index fc3d128b..f85e8600 100644 --- a/docs/backlog/backlog-light.md +++ b/docs/backlog/backlog-light.md @@ -128,9 +128,6 @@ The LED-driver increments **shipped**: increment 1 (RMT/WS2812B single-strand on - **sigrok/fx2lafw cross-check + MoonDeck "LED driver test" Python script** — the independent-clock proof and the run-from-MoonDeck flow ([analysis §5.3](leddriver-analysis-top-down.md)). The on-board RMT-RX loopback (shipped) is the cheap CI correctness gate but a *compromised witness* for WiFi-induced flicker — the RX capture runs on the same ESP32 whose WiFi causes the glitch. The real flicker test is a **sustained capture (seconds) with WiFi associated + a packet flood**, decoding every frame for a byte-slip or reset-gap deviation; it belongs with the core-1 driver-task work below, since that task pinning is the *fix* it validates. A DSLogic Plus (100 MS/s) upgrade is reactive — only if a flicker reproduces that 24 MS/s can't resolve. - **Dedicated core-1 driver task + per-module core-affinity control** ([analysis §7.2](leddriver-analysis-top-down.md)) — the WiFi-glitch mitigation, shared across all the LED drivers. (See also [backlog-core § Task core-pinning](backlog-core.md#task-core-pinning-backlog) for the general task-pinning question.) - **`rmtWs2812Show` fuller error handling** (deferred from PR #17 / 🐇 CodeRabbit). The shipped path has a finite `rmt_tx_wait_all_done` timeout (1 s) so a wedged DMA can't hang the render tick forever, and a dropped frame self-heals (the driver re-encodes the whole frame next tick). The fuller version — `rmt_transmit` return check, `rmt_tx_stop` to cancel an in-flight transfer on timeout, `show()` returning failure so `loop()` won't reuse `symbols_` mid-transmit — belongs with the **core-1 driver-task** work, since that task owns the buffer lifetime and in-flight state the cancel logic needs. -- **Per-driver buffer window** — `start`/`count` controls on each physical driver, so different slices of the light buffer can go to different outputs (e.g. "some lights to ArtNet, others to LED pins"). Additive on `DriverBase` consumers when it lands — no change to the Drivers container or the buffer-passing contract; the multi-pin RMT slicing would then subdivide the driver's window instead of the whole buffer. - - **This is the model for light distribution — distribution is *explicit*, not derived from driver order.** Worth stating because it's a common expectation otherwise: every driver reads the **same shared source buffer** ([`Drivers::passBufferToDrivers`](../../src/light/drivers/Drivers.h) hands the same `Buffer*` to every child) and selects *its* lights from *its own* controls — `NetworkSendDriver` via `universe_start` + `light_count` ("0 = whole buffer, >0 = the first N"), the LED drivers via their `pins` / `ledsPerPin`. There is **no running offset across driver siblings**, so **reordering drivers via drag-and-drop does not change which lights each driver outputs** — it only changes tick order and the persisted file order. A "split the buffer across drivers by sibling order" model (some controllers do this) is explicitly *not* what projectMM does; this `start`/`count` window is the deliberate alternative — the user says which slice goes where, order-independently. **Estimate: small — 1–2 commits.** Add `start` (alongside the existing `light_count` as `count`) to `DriverBase`'s windowing, clamp to the source buffer, apply in each driver's read loop, plus a unit test (two drivers, non-overlapping windows, assert each emits its slice) and a doc line. The wire/output loops already read a sub-range, so this is mostly lifting `light_count`'s "first N" into a "[start, start+count)" window on the shared base. - **Auto-derived DMA buffer count** (7 / 30 / 75 per [analysis §7.4](leddriver-analysis-top-down.md)), **16-bit pipeline + dither** ([§7.3](leddriver-analysis-top-down.md)), **shift-register expander stubs** ([§7.5](leddriver-analysis-top-down.md)). - **Moving-head preview = peer interpreter.** When moving heads land, the previewer must interpret channel semantics (pan/tilt/RGBW-at-arbitrary-indices) to render a moving fixture — the same light-preset model physical drivers use, interpreted to screen. This is *why* the increments named the abstraction "interpret the preset" rather than "apply correction / opt out": so Preview becomes a full peer here without a rename. Its own design plan when moving-head support starts. diff --git a/docs/backlog/rename-to-moonlight.md b/docs/backlog/rename-to-moonlight.md new file mode 100644 index 00000000..ea87fe74 --- /dev/null +++ b/docs/backlog/rename-to-moonlight.md @@ -0,0 +1,135 @@ +# Rename projectMM → MoonLight (phased) + +The project will be renamed **projectMM → MoonLight**, and two repos swap names at the same time: + +| Repo today | Becomes | What it is | +|---|---|---| +| `github.com/MoonModules/MoonLight` | `github.com/ewowi/MoonLight` | the **predecessor** MoonLight (this project's prior art) moves to a personal repo | +| `github.com/MoonModules/projectMM` | `github.com/MoonModules/MoonLight` | **this project** takes over the `MoonModules/MoonLight` name | + +The hard part is the **name collision**: while the move is in progress, `MoonModules/MoonLight` means *two* different things, and the same word "MoonLight" appears in this repo as both (a) the future product name and (b) prior-art prose/links pointing at the predecessor. Sequencing exists to make that collision a non-event. + +## Blast radius (measured) + +- **`projectMM` → `MoonLight`:** ~579 references across ~135 files. Concentrated in `docs/` (history/plans, moonmodules specs, install), then `src/` (core, light/effects, ui), `scripts/build`, and `test/`. +- **Predecessor links to repoint** (`github.com/MoonModules/MoonLight` → `github.com/ewowi/MoonLight`): ~23 URLs, all in `docs/` prior-art / history sections. +- **`MoonLight` as prior-art prose** (NOT the new product name): ~25 mentions in `docs/history` + light specs — these must NOT be swept into the rename; they describe the predecessor. +- **`MoonLive`** (the on-device scripting engine, 29 files): a *different* name, NOT part of this rename. Any sweep must exclude it. + +### High-stakes, externally-visible references (these gate the switch) + +These are the ones that break running devices, OTA, or the installer if mistimed — they change **at the switch**, not before: + +- **Binary name** `projectMM.bin` (`library.json`, `scripts/build/flash_esp32.py`, `generate_manifest.py`, `package_desktop.py`) — renaming changes OTA asset names and the web-installer manifest; old + new must line up with the release that ships under the new repo. +- **OTA download URLs** `github.com/MoonModules/projectMM/releases/...` (`docs/install/install.js`, `FirmwareUpdateModule`) — deployed devices fetch updates from here; the URL only resolves to the new repo after the repo rename. +- **mDNS / device identity** the `MM-XXXX` hostname prefix (`SystemModule.h`) — devices on the LAN announce as `MM-….local`. Changing the prefix (e.g. to `ML-`) renames every device's network identity; deliberately deferred (own decision: keep `MM-` or move to `ML-`). +- **Repo URL** in docs/READMEs/`package_desktop.py` source links. + +## The predecessor move: temporary fork, then transfer at the switch + +The predecessor (`MoonModules/MoonLight`) has to **vacate** its name so this project can take it. The mechanism matters: + +- A **fork** does *not* free the name — the original stays put. But a fork *does* give our to-be-changed links a **valid target right now**. +- A **transfer** frees the name and redirects old URLs — but transferring *now* would invalidate every public reference to `MoonModules/MoonLight` before this project is ready to receive the name. + +So the plan uses the fork as **scaffolding**, deferring the real transfer to the switch: + +1. **Now:** fork `MoonModules/MoonLight` → `ewowi/MoonLight` (a temporary copy). Our ~23 deep permalinks (`/blob/main/src/MoonLight/Layers/PhysicalLayer.h`, etc.) resolve against the fork's identical tree. +2. **Now:** repoint our links `MoonModules/MoonLight` → `ewowi/MoonLight`. Only the owner segment changes; the in-repo `src/MoonLight/...` path is the predecessor's own structure and stays. Links are valid immediately against the fork. +3. **During the switch:** **delete the fork, then transfer** the canonical `MoonModules/MoonLight` → `ewowi/MoonLight`. GitHub refuses a transfer onto an existing repo, so delete-first is required (and correct). The canonical repo lands at the address our links already point to; GitHub's redirect covers the brief 404 window between delete and transfer. + +**Watch-items for this approach:** +- **Permalink rot is a non-risk here** — the fork is scaffolding we don't develop on, so its `main` stays put and the `/blob/main/` links hold until the switch. (SHA-pinning would only get undone when the canonical repo lands, so it's not worth doing for a temporary fork. If the fork's `main` ever *does* move a cited file before the switch, the worst case is a prior-art link one commit off — self-corrects at the switch.) +- **The delete→transfer window** briefly 404s `ewowi/MoonLight` — but you own both repos, so it's two back-to-back actions (seconds apart), not a coordinated handoff. Do them in immediate succession and the exposure is negligible. + +## Guiding rules + +- **GitHub auto-redirects help, but aren't forever.** A renamed/transferred repo serves redirects from the old path, so old release URLs and clones keep working for a while — but a *new* repo can later claim the freed `MoonModules/projectMM` name and break them. Treat redirects as a grace window, not a permanent crutch: repoint links during the rename, don't rely on them after. +- **One mechanical sweep, reviewed, not 135 hand-edits.** The bulk `projectMM → MoonLight` change is a scripted find-replace with an explicit exclude list (`MoonLive`, predecessor-MoonLight prose, the `MM-` mDNS prefix until decided), landed as its own commit so the diff is auditable and the gates run clean on it. +- **Predecessor links repoint independently.** The `MoonModules/MoonLight → ewowi/MoonLight` URL fix is a *separate* small sweep from the `projectMM → MoonLight` rename — different intent, different commit, so neither hides the other. + +--- + +## Phase 1 — what we can do NOW (no external dependency, no collision) + +Decoupling and groundwork that's safe while both repos still hold their current names. + +1. **Fork the predecessor, then repoint our links** (see *The predecessor move* above). ✅ **Done:** the fork exists at [`ewowi/MoonLight`](https://github.com/ewowi/MoonLight), and the ~22 prior-art permalinks in `docs/` (everything except this plan's own descriptive references) now point at `ewowi/MoonLight`. This clears the name collision so the later `projectMM → MoonLight` sweep can't confuse the two. The deep permalinks stay on `/blob/main/`; since the fork is scaffolding nobody develops on, `main` won't move and the links hold until the switch — no SHA-pinning needed (it would only be undone at the switch). +2. **Decide the open naming questions** — ✅ **decided: change nothing now; every user-facing identifier stays `projectMM`/`MM-` until the switch, then flips in one sweep.** These are externally-visible (devices, OTA assets, installer manifest), so changing them early would (a) break the "tell projectMM apart from predecessor-MoonLight on the same bench" distinction during the transition, and (b) desync the names from the repo before the cutover. The decisions: + - **mDNS hostname prefix:** keep `MM-` until the switch — deliberately, to distinguish projectMM devices from current MoonLight (`ML-`) boards on the same network during the transition. (Whether the post-switch prefix becomes `ML-` is a switch-time decision.) + - **Binary/firmware basename:** keep `projectMM.bin` until the switch (cascades to OTA asset names + installer manifest, which must line up with the first release under the new repo). + - **PlatformIO/`library.json` `name`:** keep `projectMM` until the switch. + - **npm/pip/package identifiers:** none exist (no `package.json`, no `pyproject.toml`/`setup.py`) — nothing to rename. +3. **Centralise the name** — ✅ **investigated, decided: skip *for the rename*; the sweep (step 4) covers it. The constant belongs to the library direction, not here (see note).** Centralising *to shrink the switch* doesn't pay: the centralisable runtime-identity is tiny (3 C++ network wire-strings — ArtNet/E1.31 source-name + CID — plus the boot printf), those are fixed-length protocol fields whose `memcpy(…, 9)`-style copies need rewriting *regardless*, and a constant added for a one-time event sits unused afterward — the single-purpose abstraction *Default to subtraction* / *Core grows slower than the domain* say not to add. The mechanical sweep (step 4) flips every literal — wire strings, the golden-vector tests that assert them, CMake target, UI HTML, repo URL — in **one auditable commit** where the assertion and the wire bytes move in lockstep (better than a constant, which would split them). So no constant; the sweep is the centralisation. *(Step-2 decisions stand: no value changes until the switch.)* + + > **Could we reuse `library.json`'s `name` now where a literal sits (subtraction, not a new constant)?** Surveyed `scripts/` for it — verdict: **no genuine low-hanging fruit.** ~95% of `projectMM` literals there are the **binary name** (`build/…/projectMM`, `.bin`, `.exe`, `.log`, `pkill projectMM`, crash `.ips`) which must track the **CMake target**, not `library.json` (wiring them to the product name would break the path to the file on disk); plus one **wire literal** (`_net_probe.py` ArtNet source-name, must byte-match the device) and ~15 prose/docstrings. The only product-name candidates — `generate_manifest.py`'s manifest `name`/`home_assistant_domain` — must *stay* `projectMM` today (Step 2), don't currently read `library.json`, and flip alongside `library.json` in the sweep anyway, so wiring them is new plumbing for zero present benefit. The principle (reuse an existing source of truth over a hardcoded literal) is right; it just has no payoff here because the literals are either binary-coupled or static-until-the-switch. (The real home for product-identity reuse is still the library API — see the box above.) + + > **The constant has a real future home: projectMM/MoonLight as a library.** When the project is offered as an embeddable library, a consumer will want one runtime identity to read (an "About"/banner string, the protocol source-name they can query) — *that* is the ongoing, widely-referenced use a `kProjectName` constant genuinely earns (the test the rename failed). But build it **then**, against a real library API surface (it may want to be a small `ProjectInfo` — name + version + url — not a bare string), per *Concrete first, abstract later* — not speculatively now. Tracked as a seed in [backlog-core](backlog-core.md); when the library work starts, introduce the identity constant as part of its public API and let the wire-strings + UI derive from it. +4. **Author the mechanical sweep script** — ✅ **Done:** [`scripts/rename/rename_to_moonlight.py`](../../scripts/rename/rename_to_moonlight.py), dry-run by default (`--apply` writes; reserved for switch-day Phase 3.3, *after* the repo rename). What the dry-run against today's tree established: replaces two tokens (`ProjectMM` the enum, then `projectMM`) — a plain token swap is correct for *every* form (repo URL, host path, `projectMM.bin`, product name, `deviceName` slug) since `projectMM` is never a substring of another token; file list comes from `git ls-files` so build output (`build/`, `esp32/build/`) is excluded without a brittle blocklist; `docs/history` (era record) + the rename doc itself are content-excluded. Verified: **542 hits across 113 files**, and `MoonLive` / predecessor `MoonLight` / `namespace mm` are provably never touched (0 files where their count changes). The enum rename is safe — device classification keys on the `"modules"` marker, not the label string. The script de-risks switch-day; it is NOT run with `--apply` until then. +5. **Prep MoonDeck / `moondeck.json` / bench registry** — ✅ **investigated; nothing to change now, two things flagged for switch-day.** (a) **The functional chain stays `projectMM` until the switch (and flips together in the sweep):** `moondeck_config.json`'s `process_name: "projectMM"` ↔ the CMake binary `projectMM` ↔ the `build//projectMM` run/log path ↔ `pkill projectMM`. These are tracked files the sweep rewrites in one pass, so they stay consistent — changing `process_name` early would break MoonDeck's process detection against today's binary, so don't. (b) **The sweep cannot reach the gitignored bench registry** `scripts/moondeck.json` (it's private, per [[bench-setup]]; the sweep uses `git ls-files`). Its `"board": "projectMM testbench …"` values reference catalog `name`s that *do* flip — so after the switch they'd mismatch only on your bench. **Switch-day local-tooling note: hand-update `scripts/moondeck.json` board names** (and re-provision bench devices if you want the new mDNS identity) — the sweep covers tracked files only. The MoonDeck prose (`MoonDeck.md`, code comments) flips in the normal sweep. + +## Phase 2 — the COMING TIME (staged, still pre-switch) + +Work that narrows the switch to a near-mechanical flip. + +1. **Dry-run the sweep on a throwaway branch**, run the full gate set on it (build all ESP32 variants, ctest, scenarios, check_devices, check_specs), and fix whatever the sweep gets wrong (false hits on `MoonLive`, predecessor prose, doc anchors, the fixed-length wire-protocol fields + their golden-vector tests). Throw the branch away — the point is to harden the script, not to merge early. (Per Phase 1.3, the sweep *is* the centralisation — there's no separate constant-introduction step.) +2. **Predecessor move is a single-owner action — no coordination needed.** The same account (ewowi) owns both `MoonModules/MoonLight` (source) and `ewowi/MoonLight` (destination), so the delete-fork-then-transfer at the switch is unilateral and instant — no waiting on another party, no "confirm the account is ready." The only pre-switch task is **keeping the temporary fork current** (or freezing the predecessor's `main`) so our deep links don't rot until then — and since you don't develop on the fork, that's automatic. +3. **Stage the OTA story**: cut at least one release under the *current* name so there's a known-good baseline, and decide how in-field devices migrate their update URL (rely on GitHub's redirect for the grace window; ship a firmware whose `FirmwareUpdateModule` points at the new repo so the *next* update onward is self-hosted on the new name). +4. **Draft the user-facing comms** (README banner, release note, installer copy) so the rename is announced, not discovered. + +## Phase 3 — DURING the switch (the cutover, ideally one short window) + +Ordered so the collision never materialises. Each step is small; the sequence is the product. + +1. **Predecessor vacates the name:** **delete the temporary `ewowi/MoonLight` fork**, then **transfer** the canonical `MoonModules/MoonLight` → `ewowi/MoonLight` (delete-first is required — GitHub won't transfer onto an existing repo). The canonical repo now sits where our links already point; `MoonModules/MoonLight` is free. +2. **This repo takes the name:** `MoonModules/projectMM` → `MoonModules/MoonLight`. GitHub redirects `projectMM` URLs for the grace window. +3. **Run the mechanical sweep** (`projectMM → MoonLight`, the hardened script from Phase 2) on a branch off the just-renamed repo — one auditable commit. Run the **full gate set** (every ESP32 variant, ctest, scenarios, check_devices, check_specs, host tests). This is where the binary name, OTA URLs, repo URLs, and `library.json` flip to MoonLight. +4. **Apply the deferred identity changes** decided in Phase 1.2 (mDNS prefix, binary basename) in the *same* sweep commit if chosen, so device identity and asset names change once, together. +5. **Cut the first MoonLight release** under the new repo: tag, build all firmwares, publish assets under the new names, regenerate the installer manifest. Verify the web installer flashes from the new URLs and a device OTA-updates from the new repo. +6. **Bench-verify on real hardware** (S31 + S3 + P4 at minimum): flash from the new installer, confirm mDNS announces the new identity, confirm OTA pulls the new release. +7. **Flip the user-facing comms** (README, release notes, installer copy) and announce. + +### Post-switch cleanup +- Sweep for any `projectMM` the script missed (grep should return zero outside `docs/history`, which legitimately records the old name). +- Leave a redirect note / tombstone where useful; don't *rely* on GitHub's redirect long-term (a future repo could reclaim `MoonModules/projectMM`). +- Update this backlog item's outcome and, once the lesson is absorbed, delete it (per *Mandatory subtraction*). + +## Feature gaps to close before the switch (MoSCoW) + +Taking the **MoonLight** name sets an expectation: someone arriving from the predecessor (60+ effects, 11 driver types, memory-optimised mapping) should not find the new MoonLight a *downgrade*. Today projectMM has ~20 effects, 4 LED-driver types, 4 layouts, 6 modifiers. The gap is real; the question is which parts must close *before* the rename so the name isn't oversold, vs. which can land after under the new name. + +This is parity-to-take-the-name, not parity-for-parity's-sake — projectMM's architecture (live reconfiguration, robustness, the generic module/UI) is already ahead in places the count doesn't show. Prioritise what a predecessor user would *miss*, not raw feature count. + +**Live scripting is not a gap — [MoonLive](../architecture.md#moonlive-the-live-script-engine) overrules it.** The predecessor's on-device scripting was an *interpreter* lineage; MoonLive is a **native-codegen compiler** (source → typed IR → real machine code, called by function pointer at near-100% native speed in the hot path) — the architecture's named *standout*. So live scripting is a projectMM **advantage to lead with**, not a parity item to close; it is deliberately absent from the MoSCoW below. + +These are pointers to existing backlog items; the rename doesn't create new work so much as set a **bar** for which items gate it. Each links to its detailed entry rather than restating it. + +### Must — the rename is a downgrade without these +- **Effect breadth at a credible fraction of 60+** — not all 60, but enough that the library doesn't feel thin. Today's ~20 cover the common families (noise, fire, plasma, particles, audio); a Must is closing the obvious *category* gaps a predecessor user expects (see Should), not matching the count. (MoonLive softens even this: a user can *author* a missing effect on-device rather than wait for a built-in.) +- **Mapping / layout parity for real fixtures** — the predecessor's "memory-optimised mapping" across non-trivial fixtures (matrices, rings, cubes, custom). projectMM has Grid/Sphere/Wheel + modifiers; a Must is that a user's existing physical layout from the predecessor has a path here. +- **OTA continuity for in-field devices** (also in Phase 2/3) — a predecessor user's deployed devices must keep updating across the rename, not brick on a dead URL. + +### Should — expected, but can trail slightly under the new name +- **More LED driver types toward the 11** — projectMM has RMT, LCD, Parlio, NetworkSend. Gaps a predecessor user may rely on: 16-lane I2S parallel (classic ESP32), shift-register expanders, additional protocols. ([backlog-light](backlog-light.md)) +- **Moving-head / DMX fixture model** ([backlog-light § Fixture model](backlog-light.md)) — if predecessor users drive moving heads, this is a felt gap; long-term there, so likely Should/Could. +- **E1.31 multicast receive, async ArtNet** ([backlog-core](backlog-core.md)) — network-output completeness a show operator expects. +- **Audio-reactive follow-ups** ([backlog-light § Audio-reactive](backlog-light.md)) — projectMM has the audio pipeline; closing the effect/feature follow-ups keeps audio parity. + +### Could — nice for the launch, not blocking +- **z-axis variation in 2D effects**, **full-density interpolated preview**, **RGBW preview end-to-end** ([backlog-light](backlog-light.md)) — polish that makes the new MoonLight feel finished. +- **Runtime board presets**, **per-layout coordinate offset** ([backlog-core](backlog-core.md)) — usability wins, independent of parity. +- **Sensor input breadth** (IMU/line-in beyond the mic) — extends the platform, not core to the predecessor's identity. + +### Won't (this rename) — explicitly out of scope for the switch +- **100% effect-count parity** — chasing all 60+ before the rename would block it indefinitely; close categories, not the literal count. +- **Raspberry Pi 5 sensor input**, **fixture model for beams** ([backlog](backlog-light.md)) — post-1.0, land under the new name. +- **Renaming MoonLive** or any non-`projectMM` identifier — out of scope (see blast radius). + +**The gating question for the product owner:** which of the Musts must be *shipped* vs. *credibly announced as in-progress* at switch time? With live scripting off the list (MoonLive overrules it — and is a *lead* feature, not a gap), the remaining Musts are lighter: effect breadth and mapping/layout parity are incremental, and MoonLive's author-it-yourself path further softens the effect gap. The likely real gate is just "enough effects + a clean migration path for existing layouts" — a much shorter pole than the predecessor's headline capability would have implied. + +## Risks / watch-items + +- **`MoonLive` false positives** — the sweep must exclude it (substring of neither, but adjacent in prose); verify the exclude list catches `MoonLiveEffect`, `MoonLive.cpp`, etc. +- **Predecessor-prose false positives** — `docs/history` + light specs cite "MoonLight" as prior art; those stay pointing at `ewowi/MoonLight` and must not become self-references. +- **In-field OTA continuity** — the window between the repo rename and the first new-name release is covered by GitHub redirects; a firmware already in the field keeps updating only as long as that holds. Shipping a redirect-aware `FirmwareUpdateModule` before the switch shortens the exposure. +- **`docs/history` is exempt** — it records the projectMM era by name and should *keep* saying projectMM where it's describing that history (present-tense rule's history exception). The sweep must not rewrite history entries into a false "always was MoonLight" narrative. diff --git a/docs/history/decisions.md b/docs/history/decisions.md index 096772fa..a73cbaea 100644 --- a/docs/history/decisions.md +++ b/docs/history/decisions.md @@ -670,7 +670,7 @@ The LED drivers (Rmt/Lcd/Parlio) are the same Device-level case — user-soldere projectMM has a property most LED-controller firmware lacks: **every module reconfigures live the instant a control changes — pins, leds-per-pin, output protocol, mic pin/rate — with no reboot, immediately reactive on the next render tick.** The design note for *why* this exists lives in [architecture.md § Live reconfiguration](../architecture.md#live-reconfiguration-every-change-applies-without-a-reboot); the lineage and the *how-it-differs* are the lesson worth keeping here. -**The lineage is MoonLight's "initless drivers."** The product owner's earlier project ([MoonLight nodes.md § Initless drivers](https://github.com/MoonModules/MoonLight/blob/main/docs/develop/nodes.md)) set the same no-reboot goal at the LED-driver level, named *initless*: a driver with **no `addLeds` (FastLED) / `initLed` (Parallel LED Driver) step** — it reads a mutable Context at `show()` time, so pin allocation, leds-per-pin, RGB/RGBW and light type all change live without a restart or recompile. +**The lineage is MoonLight's "initless drivers."** The product owner's earlier project ([MoonLight nodes.md § Initless drivers](https://github.com/ewowi/MoonLight/blob/main/docs/develop/nodes.md)) set the same no-reboot goal at the LED-driver level, named *initless*: a driver with **no `addLeds` (FastLED) / `initLed` (Parallel LED Driver) step** — it reads a mutable Context at `show()` time, so pin allocation, leds-per-pin, RGB/RGBW and light type all change live without a restart or recompile. **projectMM reaches the same outcome by a different mechanism, so the word doesn't transfer.** Our drivers *do* have an explicit rebuild — `RmtLedDriver::reinit()` re-creates the RMT channels, the i80/Parlio drivers rebuild the DMA bus — so they are not "initless" in MoonLight's no-`initLed` sense. What makes the behaviour universal here is that the rebuild is driven by the **generic tier-3 `onBuildState()` sweep** ([§ Event triggering](../architecture.md#event-triggering-between-modules)), not hand-built per driver: any module that returns `true` from `controlChangeTriggersBuildState` inherits live-reconfig for free, which is why it spans drivers, the audio peripheral, effects, layouts, modifiers and network I/O alike. Lesson: credit the lineage for the *idea* (MoonLight's initless drivers), but name the property by what the user sees (*live, no-reboot reconfiguration*) when the mechanism differs — overloading a prior project's term onto a different implementation misleads. And: a generic prepare-pass buys breadth a per-driver technique can't — the same three tiers that rebuild a mapping LUT also re-target a GPIO, so the property generalised itself. @@ -765,3 +765,16 @@ Adding the S31 (a RISC-V preview chip) cost almost nothing in *our* code — it - **A chip's *minimum-revision* default can silently brick field silicon.** v6.1 defaults the P4 minimum to rev 3.1; the bench (and field) P4 is v1.3, so the stock build refused to boot ("requires chip revision [v3.1-v3.99]"). The fix (`SELECTS_REV_LESS_V3` + `REV_MIN_0`) was only *found by flashing real hardware* — it builds clean either way. Hardware re-test, not CI, is what catches a min-rev trap. - **Newest esptool-js ≠ best for flashing.** esptool-js 0.6.0 (the latest) deterministically corrupts a compressed flash — a P4 web-flash aborts at the *same* block (seq 38) every time, where 0.4.7/0.5.7 and the CLI all succeed (a fixed-seq failure, not random, rules out a transient). We pinned **0.5.7 — the version ESP Web Tools (ESPHome/WLED) ship** — over the newest, because *Common patterns first* applies to dependency versions too: match the battle-tested one the ecosystem runs, not the highest number. (0.5.x also moved `hardReset` off `ESPLoader`; driving DTR/RTS through the transport directly is version-agnostic.) - **A new chip can be CLI-flashable but not browser-flashable**, and that's an *upstream* gap, not ours: esptool.py knows the S31 (since v5.2.0), esptool-js has no S31 chip class in any version. Worse, the S31's ROM magic collides with the classic ESP32's, so esptool-js would *mis-identify* it — a version bump alone won't fix browser flashing, it needs the secondary detection esptool.py already has. The catalog ships the firmware (`ships:True`) anyway: even with no web-flash, the published release asset is the device's **OTA** update channel, which is the more-used path (flash once via CLI, OTA forever). + +## Device discovery: UDP presence (we control it), mDNS advertise-only; the transport is per-ecosystem + +The DevicesModule refactor first reached for a UDP beacon, then swung to "mDNS is the standard, use it for discovery too", and finally — after bench measurement — landed on **UDP presence for discovery, mDNS for advertise-only**. The swing is the lesson: the "use the standard" instinct was right about *announce-to-foreign-apps* and wrong about *discover-peers*, and only measuring the wire separated the two. The lessons: + +- **Discovery transport is per-ecosystem, owned by the plugin — not one mechanism for all.** Use UDP where we control both ends (projectMM↔projectMM) or a foreign system already broadcasts (WLED's 44-byte packet on UDP 65506, on by default). Use mDNS only where a foreign *app* requires it (the native WLED app discovers us *only* via mDNS `_wled._tcp` — UDP can't reach it). The `DevicePlugin` seam reshaped from mDNS-shaped (`service()`/`classify(MdnsHost)`) to transport-agnostic (`discoveryPort()`/`classifyPacket(data,len,srcIp)`) so each plugin carries its own transport. +- **Two directions of one interop can need different transports.** We discover WLED over UDP 65506; WLED's *app* discovers us over mDNS. Same ecosystem, opposite directions, different mechanisms — the plugin owns both halves. Trying to force one transport for both is what kept the design fighting itself. +- **The mDNS query was the bug, and the fix was to stop querying mDNS, not to pace it better.** A blocking PTR query for a service the device *also advertises* exhausts the IDF mDNS pool (`mdns_querier: Cannot allocate memory` *with megabytes free*) and the device's own advertisement vanishes from peers. An earlier "throttle the query" / "go passive-browse" detour each half-worked (a passive browser can't discover — nothing re-announces unsolicited; a 75 s capture proved it). Moving discovery to UDP makes the self-query-disturbs-advertise bug **structurally impossible**: mDNS shrinks to advertise-only (`mdnsInit` announces `_http`+`mm=1` / `_wled`+`mac=`; the browse/query seam is deleted). +- **Misattributing a self-inflicted bug to "the network" or "the chip" wastes hours — read the device's own serial, and the prior art's actual behaviour, first.** Chasing this I twice concluded wrongly ("the network filters multicast", "ESP32↔ESP32 mDNS is an IDF limitation") — both were our own query destabilising the boards. The product owner's logic cut through it (*the WLED app discovers WLED devices on classic ESP32s, so classic mDNS demonstrably propagates — an invisible projectMM device is OUR bug*). And reading the prior art's behaviour, not assuming it, is what made the rest safe + precise: a WLED that receives our 65506 packet *lists* us, it does not *sync* to it (confirmed from MoonLight's working code); the WLED-app interop's missing `wifi{}` field was a one-line fix once we read the WLED-Android app's actual Moshi model, not a guess. +- **Commands split three ways by two questions — must it arrive? is it latency-critical? — not "all REST".** Discovery → **UDP presence**. Must-arrive + latency-tolerant config (brightness, presets, OTA) → **REST/TCP** (~10–50 ms, delivery guaranteed). Latency-critical + lossy-OK (time sync, live pixels) → **UDP stream** (~0.5–1 ms, broadcast to N; REST would be 10–100× too slow and visibly de-sync). The trap is routing a sync pulse over REST because "it's a command". +- **A plugin seam makes "interop" our own industry-standard hook-in point.** Foreign systems hook in as `DevicePlugin`s (the adapter pattern), not hardcoded branches — one file per ecosystem, no core edit. The discovery half ships now (projectMM + WLED); the command half is a reserved extension (*concrete first, abstract later*). The seam allows **hub-shaped** plugins (Hue: a bridge → child resources + auth), not just flat devices — designing that room in now (without building it) lets the harder tiers land additively. The differentiator: one UI across WLED + ESPHome + Hue, incrementally. + +**A blocking socket op on the single render loop freezes EVERYTHING — and an intermittent freeze hides as the wrong cause.** The desktop web UI went "slow like a cow in the mud" and FPS pinned at ~18 regardless of grid size. The HTTP/WebSocket server runs *inside* the desktop render loop (`HttpServerModule::loop20ms`), and the accepted client socket carried a **2 s `SO_RCVTIMEO`**: whenever a request's bytes hadn't landed the instant `accept()` returned (the GET line a TCP segment behind the handshake), `recv()` **blocked the whole loop up to 2 s**. The two symptoms were one bug — a 2 s stall per unlucky request (the sluggishness) and a fixed ~50 ms-per-connection drag that capped the tick at ~18 FPS (the "FPS independent of grid" tell, while the Noise effect itself measured 222 FPS — proof the cost was non-render). **Fix:** the accepted socket is persistently non-blocking, `read()` returns -1 immediately when nothing's pending, and `writeSome` stops toggling it back to blocking; ~2 s → ~13 ms, FPS tracks real render speed. Lessons: (1) **a single-threaded loop that services I/O must never make a blocking call** — a timeout isn't a fix, it's the size of the freeze; non-blocking + poll is the only safe shape. (2) **"FPS constant across workload" means a fixed non-workload cost dominates** — compare the effect's own FPS to the tick FPS; a large gap points away from rendering. (3) An *intermittent* stall (timing race on byte arrival) masquerades as environment noise — it sent the debug down "zombie process / CPU busy-spin" dead ends before a profiler (thread stuck in the loop, not in render) + a "does plain curl also stall?" test isolated it. (4) It was **pre-existing on main**, surfaced only because this branch added a second per-tick socket read (the WS poll) that made the race fire more often — a latent single-loop-blocking bug waits for the load that exposes it. diff --git a/docs/history/plans/Plan-20260628 - S31 hardware ref + microphone.md b/docs/history/plans/Plan-20260628 - S31 hardware ref + microphone.md new file mode 100644 index 00000000..24b0cb47 --- /dev/null +++ b/docs/history/plans/Plan-20260628 - S31 hardware ref + microphone.md @@ -0,0 +1,166 @@ +# Plan — ESP32-S31 hardware reference + onboard microphone (ES8311), Ethernet pins, full feature list + +## Context + +While planning the S31 onboard-microphone feature, the product owner asked to also (a) collect +**all** S31 pin assignments for future use (not just audio), (b) search out **all** the board's +features (the catalog only lists Ethernet + Audio; there are more), and (c) **store all the scraped +hardware info in the docs**. So this plan has two deliverables: + +1. **A durable S31 hardware-reference doc** — the home for everything scraped from the official + Espressif ESP32-S31 Function-CoreBoard-1 schematic (pin maps for audio, Ethernet, SD, RGB, + buttons + the board feature list). This unblocks the deferred S31 Ethernet *and* the new mic + work, and is reference for anything S31 later. +2. **The S31 microphone feature** itself (ES8311 codec → the existing audio-reactive path), which + the reference's audio pins make implementable. + +Everything below the audio seam is reused: projectMM already has the full audio framework +(`AudioModule` → FFT → `AudioFrame`, the `hasI2sMic` platform seam). The S31's mic differs only in +that it routes through an **ES8311 I2S codec configured over I2C** (vs the existing direct-I2S +INMP441 MEMS mic) — so the new work is a platform-side codec-init step, behind the stable seam. + +## Scraped hardware facts (from the official schematic — the data to store) + +**Source:** `https://dl.espressif.com/schematics/esp32-s31-function-coreboard-1-schematics.pdf` +(rev C, 2026-05-13), read page-by-page. Datasheet: +`https://documentation.espressif.com/esp32-s31_datasheet_en.pdf`. + +**Audio — ES8311 codec (U7, I2C addr 0x18) + NS4150B amp (U9) + electret mic (J6) + speaker:** + +| Signal | GPIO | Notes | +|---|---|---| +| I2S_MCLK | GPIO52 | master clock to codec | +| I2S_SCLK (BCLK) | GPIO53 | bit clock | +| I2S_ASDOUT (mic in → ESP) | GPIO54 | ADC/mic data from codec | +| I2S_LRCK (WS) | GPIO55 | word select | +| I2S_DSDIN (→ codec DAC) | GPIO56 | playback data (speaker path) | +| ESP_I2C_SDA | GPIO50 | codec control bus | +| ESP_I2C_SCL | GPIO51 | codec control bus | +| PA_CTRL | GPIO57 | NS4150B amp enable | + +The ES8311 `CE` pin sets I2C addr (default **0x18**). The codec is the standard `esp_codec_dev` +ES8311 part. The **mic path uses I2S_ASDOUT (GPIO54) for record**; the speaker path (DSDIN/PA) is +out of scope for the mic feature. + +**Ethernet — YT8531 PHY (U8) → RJ45, RGMII (resolves the deferred S31 eth pins):** + +| Signal | GPIO | | Signal | GPIO | +|---|---|---|---|---| +| ETH_INTN | GPIO2 | | ETH_TXD3 | GPIO10 | +| PHY_MDC | GPIO4 | | ETH_TX_CTL | GPIO11 | +| PHY_MDIO | GPIO5 | | ETH_TXCLK | GPIO13 | +| ETH_PHY_RST | GPIO6 | | ETH_RX_CLK | GPIO14 | +| ETH_TXD0 | GPIO7 | | ETH_RX_CTL | GPIO15 | +| ETH_TXD1 | GPIO8 | | ETH_RXD3 | GPIO16 | +| ETH_TXD2 | GPIO9 | | ETH_RXD2 | GPIO17 | +| | | | ETH_RXD1 | GPIO18 | +| | | | ETH_RXD0 | GPIO19 | + +PHY = **YT8531** (Motorcomm), **RGMII** (1 Gbps), 25 MHz XTAL (Y2). Note: this is RGMII, not the +RMII our P4/classic eth uses — a different MAC config (the S31 EMAC does RGMII). Flag for the eth +implementation: the existing `ethInit` RMII path won't cover RGMII unmodified. + +**Other onboard features (from the System Block, page 1, + pin tables):** +- **RGB LED** — WS2812 (D7) on **GPIO60** (already wired: catalog RmtLed pins="60"). +- **SD card slot** — SD_D0-3 / SD_CLK / SD_CMD (the module's SDIO pins, GPIO20-25 per the user + guide). Note: `SOC_SDMMC_SUPPORTED` is absent on the S31 soc-caps — so it's likely SPI-mode SD or + a different controller; verify before claiming it. +- **USB-A host** (USB 2.0 HS, the high-speed host port) + **USB-C** ×2 (USB Serial/JTAG on one, + USB-to-UART bridge / CP2102N on the other). +- **Buttons** — BOOT (GPIO61), RESET (EN). +- **40-pin GPIO header** (J2), optional 32.768 kHz XTAL footprint. + +**SoC-level capabilities (S31 soc_caps):** WiFi 6, **BT** (Bluetooth, no separate BLE flag), +**IEEE 802.15.4** (Thread/Zigbee), **USB-OTG**, **GPSPI**, **TWAI** (CAN), RMT/Parlio/LCD_CAM-i80, +on-chip EMAC. (RISC-V dual-core — shares the MoonLive RISC-V backend with the P4.) + +## Deliverable 1 — the hardware-reference doc + +**New file `docs/reference/esp32-s31-coreboard.md`** (a new `docs/reference/` directory — the home +for board hardware references; P4/S3 reference docs can follow the same shape later). Holds the +tables above (audio/eth/RGB/SD pins + features + the schematic/ +datasheet URLs), present-tense, so any future S31 work (eth, mic, SD, USB-host) reads it instead of +re-scraping the PDF. Delete the temporary `docs/backlog/s31-microphone-spec.md` draft into it. + +**Expand the S31 catalog `planned` list.** `check_devices.py` whitelists only +`{LEDs, WiFi, Ethernet, Audio}` for **`supported`**, but **`planned` accepts any string** (its +whitelist is `None`). So the S31 `planned` can carry the fuller, honest feature list — add the ones +the board has that we don't drive yet: e.g. `Ethernet`, `Audio` (already), plus `Bluetooth`, +`Thread/Zigbee (802.15.4)`, `SD card`, `USB host`, `Speaker`, `CAN (TWAI)`. (Confirm the exact +labels with the PO; these describe the *board*, not yet projectMM modules.) + +## Deliverable 2 — the microphone feature (ES8311) + +Now implementable with the real pins. Design (unchanged from the prior research, pins filled in): + +- **Seam stays stable:** `AudioModule` keeps calling `audioMicInit(ws, sd, sck, rate)` + + `audioMicRead`; the codec init slots in *below* it. A board has either an INMP441 or an ES8311, + not both — so codec choice is a per-target property, not an `AudioModule` control. +- **New platform seam:** `audioCodecInit(CodecType, AudioCodecPins)` in `platform.h` (neutral + `CodecType{None, Es8311}`), called by `AudioModule::reinit()` before `audioMicInit()`; inert stub + on desktop / non-codec targets. +- **New `src/platform/esp32/platform_esp32_es8311.cpp`** — ES8311 init via the **`esp_codec_dev`** + managed component (record mode, mic gain, MCLK), incl. the platform's **first I2C master bus** + (I2C is new to `src/platform/esp32/`). Behind `#if SOC_I2S_SUPPORTED` + a codec gate. +- **`esp32/main/idf_component.yml`** — add `espressif/esp_codec_dev`, `rules: target == esp32s31` + (the established chip-gated managed-component pattern, like `ip101`/`w5500`). +- **`platform_esp32_i2s.cpp`** — parameterise the I2S slot if the ES8311 format differs from the + INMP441 Philips-LEFT default (the codec presents standard I2S; confirm master/slave from the + schematic — the ESP drives MCLK on GPIO52, so ESP is I2S master). +- **`deviceModels.json`** — S31 `Audio` → `supported`; add an `AudioModule` with the I2S pins + (ws=GPIO55, sd=GPIO54, sck=GPIO53) + the I2C pins (sda=GPIO50, scl=GPIO51) + MCLK=GPIO52. + +## Files + +- **New:** `docs/history/esp32-s31-coreboard.md` (the hardware reference), + `src/platform/esp32/platform_esp32_es8311.cpp` (codec init). +- **Edit:** `src/platform/platform.h` (codec seam), `src/platform/desktop/platform_desktop.cpp` + (stub), `src/platform/esp32/platform_esp32_i2s.cpp` (slot param) + `platform_config.h` (per-target + audio default), `src/core/AudioModule.h` (call codec init first; I2C-pin controls if board-var), + `esp32/main/idf_component.yml` (esp_codec_dev), `docs/install/deviceModels.json` (S31 audio + + expanded planned), `docs/moonmodules/core/AudioModule.md` (ES8311 path). Delete + `docs/backlog/s31-microphone-spec.md` (folded into the reference + AudioModule.md). + +## Riskiest parts + +1. **The mic feature can be bench-verified now — the pins are known.** The earlier blocker is gone. +2. **ES8311 master/slave + MCLK** — ESP drives MCLK (GPIO52) ⇒ ESP is I2S master; confirm the + `esp_codec_dev` config matches. +3. **`esp_codec_dev` on `release/v6.1`** — a managed component must resolve + build on the pinned + IDF; same v6.0-floor / managed-component decision class as the P4 esp-hosted exception, record it. +4. **First I2C in the platform layer** — keep the I2C master owned by the codec file, behind the + boundary. +5. **(For the later eth work, not this plan)** the S31 eth is **RGMII**, not RMII — the existing + `ethInit` RMII path needs an RGMII branch. Captured in the reference; out of scope here. + +## Verification + +- Reference doc renders, present-tense, with the pin tables + URLs; `check_devices.py` green with + the expanded S31 `planned`. +- Desktop build green (codec stub). ESP32-S31 build green with `esp_codec_dev`. Other targets + unaffected (stub). `ctest` + scenarios green (additive seam). +- **Bench (the real test):** flash the S31, add an `AudioModule` with the audio pins above, make + sound → the level + 16-band FFT respond in the UI, an audio-reactive effect lights up. Inert + audio behaviour preserved on a non-codec board (S3/P4 INMP441 path still works). +- Save the approved plan to `docs/history/plans/Plan-YYYYMMDD - S31 hardware ref + microphone.md`. + +## Decisions locked + +- **Doc home:** new `docs/reference/esp32-s31-coreboard.md` (a new `docs/reference/` dir for board + references). +- **Scope:** the reference doc + the mic feature land **together** in one feature/branch (the doc's + audio pins feed straight into the implementation). + +## Open question (minor, settle during implementation) + +- **`planned` labels:** the exact capability strings for the S31 board's not-yet-driven features — + proposed: `Bluetooth`, `Thread/Zigbee`, `SD card`, `USB host`, `Speaker`, `CAN`. Since `planned` + takes any string, I'll use clear short labels and the PO can adjust in review. + +## Out of scope + +- **Speaker / DAC output** (NS4150B, I2S_DSDIN/PA_CTRL) — separate capability. +- **S31 Ethernet implementation** — the *pins* are now captured (RGMII), but wiring RGMII into + `ethInit` is its own feature (the existing path is RMII-only). +- **SD card / USB-host / BT / Thread / CAN drivers** — board has them; projectMM doesn't, listed in + `planned` as board capabilities, not built here. diff --git a/docs/history/plans/Plan-20260629 - DevicesModule mDNS discovery + plugin interop (attempted, superseded by UDP).md b/docs/history/plans/Plan-20260629 - DevicesModule mDNS discovery + plugin interop (attempted, superseded by UDP).md new file mode 100644 index 00000000..3e9d1874 --- /dev/null +++ b/docs/history/plans/Plan-20260629 - DevicesModule mDNS discovery + plugin interop (attempted, superseded by UDP).md @@ -0,0 +1,123 @@ +# Plan — DevicesModule refactor: mDNS discovery + REST commands, plugin interop + +> **Design note.** An earlier draft reached for a custom UDP presence beacon; reasoning through what foreign systems (WLED, ESPHome, Tasmota) actually do moved it to the cleaner endpoint recorded here — **mDNS for discovery, REST for commands, foreign systems behind a plugin seam.** mDNS is the standard the whole ecosystem already speaks, so a bespoke UDP beacon was a WLED-ism worth dropping. + +## Context + +The current `DevicesModule` (627 lines) discovers LAN devices with **two strategies merged into one list**: an mDNS *browse* every tick (a ~20 ms **blocking** `mdnsBrowse` on the render task) and a one-shot HTTP subnet sweep (a **blocking** `httpGet`, 1 IP/tick, ~4 min/.24, flickers LEDs). The product owner wants a complete refactor: drop the slow HTTP scan entirely (devices should **announce themselves**, not be polled), and make the device module our own **industry-standard interop seam** that other systems hook into as plugins. + +### The design, reasoned to its endpoint + +1. **Discovery = mDNS; commands split across REST and UDP by need (see the transport table).** mDNS is *the* industry discovery standard (Bonjour/Avahi) — every OS, Home Assistant, ESPHome, WLED, Tasmota, Hue speaks it. A device **announces** its service; we **passively listen** — the push paradigm "devices announce themselves" wants, on the standard the whole ecosystem already uses. **Commands are not all one transport:** must-arrive config (brightness, presets, OTA) goes over **REST** (TCP-guaranteed, ~10–50 ms is fine); latency-critical lossy-OK traffic (time sync for synchronized effects, live pixels) goes over **UDP** (~0.5–1 ms, broadcast to N — REST is 10–100× too slow there). See § Transport split. +2. **No HTTP scan — ever.** The slow part was the HTTP *subnet sweep* (active per-IP probing, blocking, flicker-prone). mDNS is the opposite: no per-IP walk, no probing — passive listen to multicast announcements. **The fix for "mDNS was slow too" is to LISTEN non-blocking, not to BROWSE blocking.** The old code's mistake was the blocking `mdnsBrowse` (a ~20 ms tick stall); a non-blocking mDNS listener (IDF `mdns_query_async_*`, poll with timeout 0) is hot-path-safe like any other poll. +3. **Foreign systems are plugins, not hardcoded branches.** A `DevicePlugin` seam (the adapter pattern, cf. `ListSource` / `ModuleFactory`) lets each ecosystem hook in: a plugin recognises a device from an **mDNS service hit** (`_wled._tcp` → WLED, `_esphome._tcp` → ESPHome, `_http._tcp`+`mm=1` → projectMM) and fills its `Device`. Adding Tasmota / NightDriverStrip later = **one new plugin file**, no core edit. The device module is projectMM's own *industry-standard hook-in point* — light software and beyond. (The control half — translate "set brightness" into a system's JSON/`cmnd`/protocol — is a reserved extension on the same seam, added when a consumer exists; *concrete first, abstract later*.) +4. **projectMM is discovered the same way it discovers — via mDNS.** It already advertises `_http._tcp` + a `mm=1` TXT (`platform_esp32.cpp` ~line 1025). No custom UDP beacon for projectMM↔projectMM: the standard advertise + listen covers it, and the same advertise makes projectMM discoverable by Home Assistant / any Bonjour client. + +**Why not UDP (the WLED way)?** WLED's UDP port (21324) bundles three jobs: discovery (the historical artifact — WLED *also* has `_wled._tcp` mDNS, so its UDP discovery is redundant even there), state-sync (lossy-OK, reasonable on UDP), and realtime pixels (correct on UDP). WLED's "messages didn't arrive" pain came from treating UDP state-sync as *reliable*. We separate the jobs by transport — mDNS (discovery) + REST (must-arrive) + UDP (lossy streams only) — so we never inherit that conflation. + +This refactor is mostly **subtraction**: the blocking HTTP sweep, the blocking mDNS browse, the `via` bitmask, the scan button + progress control, and the HTTP-body classifier all go; discovery becomes a non-blocking mDNS listener feeding a small plugin list. Net core lines drop. + +## Sanity check against the docs + +- **README / CLAUDE.md / architecture.md:** honours *Common patterns first* (mDNS-SD is THE textbook LAN discovery standard; the plugin seam is the textbook adapter pattern), *Default to subtraction* (removes the sweep + browse + classifier), *Hot path discipline* (the non-blocking mDNS poll replaces two blocking calls), *Industry standards, our own code* (mDNS the standard, our own listener + plugin model written fresh), and *Robust to any input* (an unrecognised service / malformed TXT is ignored, never crashes). +- **Supersedes** backlog-core's "mDNS preferred for discovery, UDP for streams, HTTP sweep as fallback" — we keep mDNS-for-discovery (vindicated) but drop the HTTP-sweep fallback and make mDNS a **non-blocking listener** + a plugin seam. The lesson (WLED's UDP-discovery is a historical artifact; separate transports by job) goes to `decisions.md`. +- **What it supersedes:** backlog-core's four-mechanism wording — it *keeps* "mDNS for discovery" (vindicated) but drops the HTTP-sweep fallback entirely and makes mDNS a **non-blocking listener** behind a **plugin seam**. The lessons (WLED's UDP-discovery is a historical artifact; commands split three ways by must-arrive × latency, not "all REST") go to `decisions.md`, and the backlog stance is rewritten in the same change. + +## Design + +### Transport split — three categories on two axes (must-arrive? latency-critical?) + +The split is **not** "discovery=mDNS, everything-else=REST". Commands divide by *two independent questions* — must the message arrive, and is it latency-critical — giving three transports. The trap to avoid: treating *every* command as REST. A **config** command is REST; a **sync pulse** is UDP. + +| Job | Transport | Latency (ESP32 LAN) | Why | +|---|---|---|---| +| **Discovery** | mDNS (announce + non-blocking listen) | n/a (background) | The standard the whole ecosystem announces on; passive listen, no scan. | +| **Config commands** — must-arrive, latency-tolerant (set brightness, save a preset, push config, fleet OTA) | REST `/api/control` (already built) | ~10–50 ms (TCP handshake + request/response round-trip) | Delivery must be guaranteed; 10–50 ms is invisible for a config change. TCP's ACK/retransmit is the point. | +| **Sync + live streams** — latency-critical, lossy-OK (time sync for synchronized effects, SuperSync clock, live pixel data) | UDP (already: NetworkSend/Receive) | **~0.5–1 ms** one-way, broadcast to N at once | Needs few-ms determinism + fan-out to many devices simultaneously; a dropped pulse self-corrects on the next one. **REST would be 10–100× too slow and is point-to-point** — wrong tool. | + +**Why time-sync is UDP, not REST (the latency answer):** a synchronized-effect clock pulse needs sub-few-ms, deterministic delivery, **broadcast to every device at once** — and is inherently lossy-OK (the next pulse corrects drift). REST is ~10–50 ms with jitter, point-to-point (N devices = N serialized TCP exchanges), and waits for an ACK you don't want. That's the textbook UDP-lossy-stream case — the *same* category as live pixels, already reserved here. Routing sync over REST would visibly de-sync the LEDs; this is the one place "REST for commands" must **not** apply. + +### Discovery: a non-blocking mDNS listener feeding a plugin list + +- **Announce (already done):** projectMM advertises `_http._tcp` + a `mm=1` TXT (`platform_esp32.cpp` ~line 1025). Adding a `_wled._tcp` advertise (one more `mdns_service_add`) makes projectMM appear in the native WLED apps (iOS/Android/Desktop all browse `_wled._tcp` — same Flutter discovery; one advertise covers all three) and in Home Assistant / any Bonjour client. *Caveat:* Android's NSD is stricter than Apple Bonjour about a well-formed instance/SRV/TXT record, so the advertise must be clean `_wled._tcp` — bench-verify on Android, not just iOS. +- **Listen (the new work):** a **non-blocking mDNS listener** — start an async query (IDF `mdns_query_async_new`), poll it each `loop1s` with **timeout 0** (`mdns_query_async_get_results`), collect any results, restart. This is hot-path-safe (a non-blocking poll), replacing the old **blocking** `mdnsBrowse` (~20 ms tick stall). The module cycles through the service types its plugins care about (`_http._tcp`, `_wled._tcp`, later `_esphome._tcp`, `_hue._tcp`), one async query at a time. +- **Plugins classify each hit:** a `DevicePlugin` seam (the adapter pattern, cf. `ListSource` / `ModuleFactory`) — each plugin says which service type(s) it claims and turns an mDNS hit (service type + TXT records + resolved IP/name) into a `Device`. `_http._tcp`+`mm=1` → projectMM; `_wled._tcp` → WLED. A new system is **one new plugin file** listed in the module — no core edit. **Plugins are not all the same shape:** a flat-device plugin (WLED, ESPHome, Tasmota) yields one device per hit; a **hub** plugin (Hue) yields a *bridge* whose children (Zigbee bulbs) are enumerated + controlled via the bridge's authenticated REST API — so the seam must not bake in "flat device", and the (reserved) command half must handle a hub addressing a resource by id, with per-plugin auth state. (Hue is the canonical "more than WLED" case driving this.) + +### The DevicePlugin seam (built now: discovery half; reserved: command half) + +``` +struct DiscoveredDevice { DevType type; char name[24]; /* hub: resource list later */ }; + +class DevicePlugin { + virtual const char* name() const = 0; // "projectMM", "WLED", "Hue"… + // Which mDNS service this plugin claims (e.g. "_wled","_tcp"). + virtual const char* service() const = 0; + virtual const char* proto() const = 0; + // Turn a resolved mDNS hit (name + TXT) into a device; false to decline. + virtual bool fromMdns(const platform::MdnsHost& host, DiscoveredDevice& out) const = 0; + // (reserved) virtual bool command(const DiscoveredDevice&, const DeviceCommand&) const; +}; +``` + +Built minimal-but-real now (the **discovery** half: two concrete plugins — projectMM + WLED — proving the seam isn't shaped to one system). The **command** half (`command()` + capability/auth) is a reserved extension added when a control consumer exists; the discovery code and the module's iteration don't change. *Concrete first, abstract later.* + +### What collapses (the subtraction) + +- **Delete** the HTTP subnet sweep (`restartScan`, `stepScan`, `probe`/`probePort`, the per-IP blocking `httpGet`, the `scan` button, the `progress` control, `kProbe*` constants). Devices announce themselves; we never poll. +- **Delete** the HTTP-body classifier (`classifyDevice`/`extractDeviceName`/`extractStringAfter` in `DeviceIdentify.h`) — classification now comes from the mDNS hit's service type + TXT, in the plugins. `DeviceIdentify.h` shrinks to the `DevType` enum + `devTypeStr`. +- **Replace** the blocking `mdnsBrowse` browse strategy with the non-blocking listener; the `via` bitmask collapses (mDNS is the one discovery source now — `speaks` may stay for "what protocol can I talk to it with", or also go; decide at implementation). +- **Keep** the `Device` struct (trim `via`), the `ListSource` rendering, persistence (last-known list on boot), age-out, self-row — the parts that *work* and that consumers (UI, main.cpp) depend on. + +### Platform (the one new seam) + +- **New non-blocking mDNS listener seam.** The existing `mdnsBrowse` is *blocking*; the refactor needs `platform::mdnsListenStart(service, proto)` + `platform::mdnsListenPoll(cb)` (timeout-0 async poll), implemented on ESP32 via `mdns_query_async_*` and a desktop stub (no mDNS on host). This is the only platform addition — it's real work but it's the *right* seam (the blocking browse was the smell). The existing `MdnsHost` POD (resolved IP/hostname/port + TXT marker) is the result type; extend it with general TXT-record access if a plugin needs a TXT beyond `mm=1`. + +## Why this matters — the multi-ecosystem positioning (a selling point) + +The plugin seam isn't only clean architecture; it's a **differentiator**. projectMM/MoonLight becomes a **hub that discovers and controls every light on the LAN — one UI across ecosystems**, not just its own LEDs. WLED can't drive Hue; Home Assistant can but is heavyweight. A lightweight LED controller that *also* sees and steers WLED, ESPHome, and Hue from one device list is a genuine pitch. This is why "device interop must be our own industry-standard seam" (the product owner's framing) is right: the device module is the **hook-in point**, and each ecosystem is a plugin. + +The seam delivers it **incrementally**, on an honest difficulty gradient (state it plainly so it's not oversold): + +| Tier | Systems | What the plugin needs | Seam fit | +|---|---|---|---| +| **Easy** | WLED, ESPHome | mDNS discovery + REST/JSON commands — flat devices | Fits **today** — the seam built now | +| **Medium** | Philips Hue, IKEA Trådfri | mDNS discovery + a **hub** model (bridge → child resources) + per-plugin **auth** (Hue link-button key, Trådfri CoAP/DTLS PSK), still IP/REST | The seam is **designed to allow** this (hub-shaped `DiscoveredDevice` + auth in the command half) | +| **Hard (IP)** | Tasmota-MQTT, zigbee2mqtt, Matter | a transport the device doesn't have yet — an **MQTT client** or a **Matter stack** | "plugin **+ a transport addition**" — still clean, but a bigger lift; don't promise casually | +| **Hard (radio, board-gated)** | direct Zigbee / Thread bulbs (IKEA, raw Hue bulbs, no bridge) | the **802.15.4 radio** (S31 / C6 / H2 only) + the esp-zigbee / OpenThread stack — projectMM *is* the coordinator, talking to bulbs over the mesh directly | Same plugin philosophy, **non-IP transport + board-gated**. The biggest differentiator (a WiFi LED controller that *also* drives your Zigbee bulbs with no gateway) and the biggest lift. Far future. | + +So the **promise to make**: *"pluggable — WLED, ESPHome, and Hue-class systems hook in as plugins; MQTT/Matter and direct-radio Zigbee need a transport addition first (the radio one only on 802.15.4 boards)."* The Easy tier ships with this refactor (WLED + projectMM); the rest are future plugin files (+ a transport for the Hard tiers), each landing without a core change. That incrementality — *one plugin at a time, no core churn* — is what makes "controls the whole LAN's lights" a credible roadmap. **The S31's Thread/Zigbee radio is the standout future card here:** unlike every IP plugin (which talks to a *bridge*), a radio plugin makes projectMM the *hub itself* — direct to the bulbs, no Hue/Trådfri gateway. A separate, board-gated capability, but the same seam philosophy. + +## Files + +- **Edit:** `src/core/DevicesModule.h` (rewrite — mDNS-listener discovery iterating plugins, drop the sweep + blocking browse, trim `via`), `src/core/DeviceIdentify.h` (shrink to the enum + label), `src/platform/platform.h` + `platform_esp32.cpp` + `platform_desktop.cpp` (the non-blocking mDNS listen seam + the `_wled._tcp` advertise), `src/main.cpp` (pass the numeric version if a plugin needs it), `docs/moonmodules/core/DevicesModule.md` (rewrite the discovery section + the plugin model), `docs/backlog/backlog-core.md` (rewrite the four-mechanism stance), `docs/history/decisions.md` (the lesson). +- **New:** `src/core/DevicePlugin.h` (the seam + the two concrete plugins). A `_wled._tcp` advertise (a few lines in `platform_esp32.cpp`). + +## Riskiest parts + +1. **The non-blocking mDNS listener** is the real work — `mdns_query_async_*` must be driven correctly (start / poll-timeout-0 / collect / delete / restart) without leaking search handles (the old blocking browse had a handle-lifetime crash the synchronous call avoided; the async path must manage the handle across ticks carefully — own exactly one in-flight query at a time, delete it before starting the next). +2. **mDNS reliability across subnets / AP isolation** — multicast can be filtered by guest-AP isolation; not a regression (mDNS had this before), note it. +3. **Plugin order / ambiguity** — a projectMM device advertises `_http._tcp`; so does a generic web box. The `mm=1` TXT disambiguates (the projectMM plugin requires it; a bare `_http._tcp` hit is generic or skipped). Pin this in a test. +4. **Persistence shape change** — the persisted list drops `via`; the restore path must tolerate an old file that still has it (the keyed reader already ignores extra keys — robust to any input). +5. **Hub plugins (Hue) are deferred but must not be designed out** — the seam's `DiscoveredDevice` + the reserved `command()` should leave room for one hit → many resources + auth; don't bake in flat-device. + +## Verification + +- **Desktop:** unit tests for each plugin's `fromMdns` (a `_wled._tcp` hit → `Device{type:Wled}`; a `_http._tcp`+`mm=1` hit → projectMM; a bare `_http._tcp` hit → generic/declined; a malformed/empty hit → declined). Pure, host-testable like `DeviceIdentify` was — feed a synthetic `MdnsHost`, assert the classification. +- **Scenario:** mDNS-hit upsert → age-out → list-serialize, with canned `MdnsHost` results fed through the listener seam's desktop stub (a settable result table — the desktop-mock the old backlog item wanted, now an mDNS source). +- **Bench (the real test):** a projectMM device + a WLED device + an ESPHome/HA instance on the LAN → projectMM lists the WLED (and projectMM peers) within a discovery cycle; the projectMM device appears in a real WLED's list AND in a **native WLED app — both iOS and Android** (Android NSD is the stricter `_wled._tcp` consumer); no LED flicker (the listener is a non-blocking poll). Run on the S3 + ESP32-16MB + a WLED unit + the phone apps. +- Full gate set (build all ESP32 variants, ctest, scenarios, check_devices, check_specs, KPI — discovery drops from a blocking sweep + blocking browse to a non-blocking poll, a KPI/tick win to note). + +## Decisions — LOCKED + +1. **mDNS for discovery; commands split by must-arrive × latency-critical (three transports, not two).** mDNS = discovery (the standard every system announces on). **REST** = must-arrive, latency-tolerant config (set brightness, presets, OTA) — TCP guarantees delivery, ~10–50 ms is fine. **UDP** = latency-critical, lossy-OK (time sync for synchronized effects, live pixels) — ~0.5–1 ms, broadcast to N at once; REST would be 10–100× too slow here. The trap is "all commands = REST": a *config* command is REST, a *sync pulse* is UDP. This is precisely the three jobs WLED conflated on one UDP port; we keep them on the right transport each. +2. **No HTTP scan, ever — and no blocking mDNS browse.** Discovery is a **non-blocking** mDNS listener (async query polled at timeout 0). The slow part was active per-IP probing (HTTP sweep) AND the blocking browse; both go. The HTTP-body classifier (`classifyDevice` et al.) is deleted with the sweep. +3. **Foreign systems are plugins.** A `DevicePlugin` seam (adapter pattern) lets a system hook in as one file (claim a service type, classify an mDNS hit). Two concrete plugins now (projectMM + WLED); ESPHome/Tasmota/Hue later are additive. The seam allows hub-shaped plugins (Hue: bridge → resources + auth), not just flat devices. +4. **Appear in the WLED app ecosystem.** Advertise `_wled._tcp` so the native WLED iOS/Android/Desktop apps (and Home Assistant) list projectMM devices. One advertise; bench-verify Android specifically. + +## Out of scope (named, for later) + +- **The command half of the plugin seam** — `command()` + per-plugin capability/auth, so projectMM can *control* a discovered foreign device (set WLED brightness via its JSON API, a Hue resource via the bridge's authenticated CLIP API, a Tasmota via `cmnd`). Built when a control consumer exists; the discovery seam is shaped to accept it (incl. hub plugins). +- **Themes / device groups** (sync a command to a group — brightness, palette) — rides REST (must-arrive), built on top of the device list this refactor produces. +- **SuperSync / synchronized clocks** — a UDP lossy stream, a separate feature. +- **Additional discovery plugins** — ESPHome (`_esphome._tcp`), Tasmota, NightDriverStrip, Hue (`_hue._tcp`, hub-shaped) — each a new plugin file against this seam, no core change. +- **Live peer STATE in the list (brightness, on/off, …), not just identity.** Discovery today carries identity only — name, IP, type — and a peer's **name** comes from the mDNS announcement itself (the `_http._tcp` instance name *is* the deviceName; no REST call needed to learn it), so a rename propagates on the next query (within the ~`kQueryEverySec × kPluginCount`-second cycle, live, no UI re-query). But mDNS is discovery-only: it does **not** carry mutable state like brightness or power. To show a peer's *current* brightness/on-off in the device list and have it update live, the device must **poll the peer's REST `/api/state` (or `/json/info` for WLED) periodically** once it has the IP — i.e. discovery (mDNS, gets IP+name) then state (REST, gets brightness etc.), on a slow cadence off the hot path. This is the read counterpart of the command half: the same per-plugin "how do I talk to this system over REST" the control path needs. A future feature; the device list + the plugin seam are the foundation it builds on. diff --git a/docs/history/plans/Plan-20260629 - UDP device discovery + mDNS advertise-only (shipped).md b/docs/history/plans/Plan-20260629 - UDP device discovery + mDNS advertise-only (shipped).md new file mode 100644 index 00000000..575f48b1 --- /dev/null +++ b/docs/history/plans/Plan-20260629 - UDP device discovery + mDNS advertise-only (shipped).md @@ -0,0 +1,105 @@ +# Plan — UDP device discovery (projectMM + WLED), mDNS becomes advertise-only + +> Builds on the shipped WLED-app interop (mDNS `_wled._tcp` advertise + `/json/info` + WS state). This plan changes how a projectMM device **discovers** other devices on the LAN — moving from mDNS *query* to passive **UDP listen**, per the bench evidence gathered 2026-06-29. + +## Why (the evidence, already measured) + +- **mDNS discovery is query-driven and the query destabilises our own advertise.** A blocking PTR query for a service we also host exhausts the IDF mDNS pool and makes our `_http`/`_wled` advertisement vanish from peers. Bench-confirmed: with querying disabled, every board became reliably discoverable. +- **A passive mDNS browser can't replace the query** — no device on the LAN re-announces unsolicited (75 s capture: zero announcements), so there's nothing to passively hear over mDNS. +- **But UDP broadcast discovery IS passive and reliable.** projectMM controls its own beacon (both ends ours). WLED broadcasts a 44-byte status packet on **UDP 65506** every ~30 s by default (`token==255, id==1`) — measured live on two reference WLEDs. So *both* ecosystems can be discovered by **listening to UDP broadcasts**, with no querying. +- **mDNS advertise stays REQUIRED** — the WLED native app discovers *us* only via mDNS `_wled._tcp` (confirmed in its source, `DeviceDiscovery.kt`). UDP can't replace that direction. So mDNS shrinks to **advertise-only**: we announce so foreign apps find us; we never query. + +Net effect: discovery becomes pure passive UDP receive, the self-query-disturbs-advertise bug is **structurally impossible**, and mDNS does only the one thing it's needed for (making us visible to WLED apps / Home Assistant). + +## Sanity check against the docs + +- **Common patterns first / Industry standards, our own code:** UDP broadcast presence is the textbook LAN-discovery-without-infrastructure pattern; WLED's 65506 packet is a documented wire format we *observe and re-implement fresh* (not copy — per [[no-wled-mm-derivation]] and the MoonLight `ModuleDevices.h` reference we read for the byte layout). mDNS-for-advertise is the standard service-announce. +- **Default to subtraction / Complexity lives in core:** removes the mDNS-query path from DevicesModule + the platform; the UDP receive primitive is a small core seam each plugin leans on. +- **Robust to any input:** a malformed/short datagram is dropped, never crashes (the robustness contract); the plugin classify stays defensive. +- **Hot path discipline:** the UDP listen is a non-blocking `recvFrom` drained on `loop1s`/`loop20ms`, off the render path — same shape as the current mDNS poll. + +## The transport split (the end state) + +| Plugin | Discovers peers via | Makes us discoverable via | +|---|---|---| +| **MmPlugin** (projectMM↔projectMM) | **UDP broadcast** — our own presence packet on a chosen port, `255.255.255.255` | the same UDP broadcast | +| **WledPlugin** (WLED / HA / WLED app) | **UDP 65506 listen** — WLED's 44-byte beacon (`token==255,id==1`, byte 38 board type) | **mDNS `_wled._tcp` + `/json/info` shim** (the WLED app only does mDNS — unchanged, already shipped) | + +Both discovery paths are passive UDP receive. **No plugin queries mDNS.** mDNS is advertise-only (`mdnsInit` keeps announcing `_http._tcp`+`mm=1` and `_wled._tcp`+`mac=`; the `mdnsListenPoll` query path and its DevicesModule caller are removed). + +## Design + +### 1. Reshape the `DevicePlugin` seam from mDNS-shaped to transport-agnostic + +Today the seam is `service()`/`proto()`/`classify(MdnsHost&)`. Replace with a UDP-discovery shape: + +``` +class DevicePlugin { + virtual const char* label() const = 0; + // The UDP port this plugin listens on for presence packets (0 = none). + virtual uint16_t discoveryPort() const = 0; + // Classify a received datagram from `srcIp`. Returns true + fills `out` (type, name) + // when this plugin owns the packet; false to decline. Defensive: a short/garbage + // datagram on the claimed port → decline, never crash. + virtual bool classifyPacket(const uint8_t* data, size_t len, const uint8_t srcIp[4], + DiscoveredDevice& out) const = 0; + // (reserved) command(...) — unchanged, still future. +}; +``` + +- **MmPlugin:** `discoveryPort()` = the projectMM presence port (a fixed port we pick — e.g. reuse/define one distinct from 65506 so we don't collide with WLED; the MoonLight precedent uses 65506 for WLED-compatible + 65507 for its own — we can broadcast a projectMM packet on our own port). `classifyPacket` recognises our own presence packet (a small fixed header with a magic + the deviceName + IP) → `DevType::ProjectMM`. +- **WledPlugin:** `discoveryPort()` = 65506. `classifyPacket` validates `len>=44 && data[0]==255 && data[1]==1` → `DevType::Wled`, extracting the name (WLED's packet carries the hostname at bytes 6–37) and the source IP. + +### 2. projectMM presence broadcast (the MmPlugin's "make us discoverable") + +A small periodic broadcast (every ~10–30 s, slow like WLED's) of a projectMM presence packet: magic bytes + protocol version + our IP + deviceName. Sent from the platform (it owns the socket + the broadcast address), driven on a slow `loop1s` cadence from DevicesModule (or a dedicated slow timer). Fixed-size, no allocation. + +### 3. Platform UDP-discovery seam + +Two small additions to `platform.h` (desktop stubs as usual): +- `udpDiscoveryListen(port)` / a shared receive that DevicesModule drains — OR reuse the existing `UdpSocket` (`bind` + non-blocking `recvFrom(srcIp)`) directly. **Prefer reusing `UdpSocket`** (already in `platform.h`, used by ArtNet) — DevicesModule owns one bound `UdpSocket` per distinct discovery port, drains each on its tick, and feeds datagrams to the plugins. No new platform primitive if `UdpSocket` covers it (it does: `bind`, non-blocking `recvFrom` with `srcIp`, `sendToAddr`/broadcast for our own beacon). +- Broadcast send for our presence packet: `UdpSocket::sendToAddr({255,255,255,255}, port, …)` — confirm the socket has `SO_BROADCAST` (add if missing; ArtNet may already broadcast). + +### 4. DevicesModule rewire + +- Drop the mDNS-query loop (`mdnsListenPoll` calls, the `queryTick_`/`serviceCursor_` rotation). +- Own a bound `UdpSocket` per distinct `discoveryPort()` across plugins (dedupe — projectMM + WLED are different ports). On `loop1s` (or `loop20ms` for snappier discovery), `recvFrom` each socket in a non-blocking loop, hand each datagram to the plugins (`classifyPacket`), upsert the recognised device. Same `Device` struct, ListSource, persistence, age-out, self-row. +- Broadcast our own presence packet on the slow cadence. +- `kStaleMs` sized to a few presence intervals (a device re-announces every ~10–30 s, so ~3× that). + +### 5. mDNS: advertise-only + +- `mdnsInit` unchanged (keep the `_http`+`mm=1`, `_wled`+`mac=`, re-advertise, symmetric stop — all shipped). +- **Remove** `mdnsListenPoll` (platform) + its decl + the desktop stub + the DevicesModule caller. mDNS no longer queries anything. +- `MdnsHost` struct + `mdnsBrowse` may stay if still used elsewhere; if not, remove (subtraction). + +## Files + +- **Edit:** `src/core/DevicePlugin.h` (seam reshape + Mm/Wled `classifyPacket`), `src/core/DevicesModule.h` (UDP listen/drain/broadcast, drop mDNS query), `src/platform/platform.h` (+ desktop/esp32 if `UdpSocket` needs a broadcast flag or a presence-send helper), `src/platform/esp32/platform_esp32.cpp` (remove `mdnsListenPoll`; presence broadcast if platform-side), `src/platform/desktop/platform_desktop.cpp` (stub adjustments). +- **Tests:** `test/unit/core/unit_DevicesModule_discovery.cpp` — drive `classifyPacket` with synthetic datagrams (a 44-byte WLED packet `token=255,id=1`; a projectMM presence packet; a short/garbage packet → declined). The `injectMdnsHitForTest` seam becomes `injectPacketForTest`. Plus the existing age-out / no-contamination cases adapted. +- **Docs:** `docs/moonmodules/core/DevicesModule.md` (UDP discovery + the transport-per-plugin table), `docs/history/decisions.md` (the transport-split lesson). + +## Decisions locked (product owner) + +- **Port: 65506, WLED-compatible 44-byte format.** projectMM broadcasts a valid `UDPWLEDHeader` (`token=255, id=1`, real IP octets, deviceName at bytes 6–37, board-type byte at 38). **Verified safe** against the product owner's concern "don't send WLED wrong info it gets confused by": per MoonLight's working code, a WLED receiving a 65506 packet uses it for **discovery only — it shows the sender in its device list and does NOT sync to it or change state**. The packet carries no command. WLED's validation is `token==255 && id==1 && ip0==localIP[0]` (a subnet check), so we set `ip0` to our real first octet. Sync/control is a *separate* concern on port 65507 which WLED never listens on — so there is no path for our presence packet to command a WLED. Bonus: real WLEDs + WLED apps that browse 65506 may see us via UDP too. +- **Cadence: ~10 s broadcast, drain on `loop1s`.** A new device appears within ~10 s; light traffic. `kStaleMs` ~60 s (≈ 6 intervals). + +## Riskiest parts + +1. **Packet contents must be a *valid* WLED header so WLED reads us but never mis-syncs** — resolved above (discovery-only by WLED's design; we fill token/id/ip0/name/type correctly). The one must-get-right: `ip0 == our real first IP octet` or WLED's subnet check rejects us. +2. **`UdpSocket` broadcast** — confirm `SO_BROADCAST` is set for the send side; receiving broadcasts needs `bind(port)` on `0.0.0.0` (already how ArtNet-in binds). On ESP32, a bound listen socket must survive netif up/down (re-bind on reconnect). +3. **Multiple bound sockets** — DevicesModule binds one per discovery port (projectMM + 65506). Bounded (≤ plugin count). Non-blocking drains, no starvation. +4. **Desktop** — `UdpSocket` works on desktop (ArtNet uses it), so discovery can actually be unit-tested with real loopback datagrams, not just stubs — a nice testability gain over the mDNS-stubbed path. + +## Verification + +- Desktop build green; `ctest` + scenarios green; ESP32 all variants green. +- Unit: `classifyPacket` accepts a real 44-byte WLED packet + a projectMM presence packet, declines garbage; no name/IP cross-contamination; age-out at the new window. +- **Bench (the real test):** all 4 boards discover each other (projectMM presence) + both reference WLEDs (65506) and hold steady — *without* the advertise instability (the mDNS advertise stays rock-solid because nothing queries it). Cross-check: the WLED native app still lists all 4 boards (mDNS advertise + `/json/info` unchanged). +- Save this plan (done); mark `… (shipped).md` when it lands. + +## Out of scope + +- **The control/command half** (`DevicePlugin::command`) — still reserved; we discover + classify, not yet command foreign devices. +- **WLED UDP sync/realtime** (ports 21324/11988) — a separate feature (driving WLED pixels), not discovery. +- **Live peer state** (a peer's brightness in our list) — still the REST-after-discovery follow-up. diff --git a/docs/install/deviceModels.json b/docs/install/deviceModels.json index 1aa0d286..c9ba34de 100644 --- a/docs/install/deviceModels.json +++ b/docs/install/deviceModels.json @@ -550,6 +550,16 @@ "sckPin": 6 } }, + { + "type": "RmtLedDriver", + "id": "OnboardLed", + "parent_id": "Drivers", + "controls": { + "pins": "48", + "start": 0, + "count": 1 + } + }, { "type": "RmtLedDriver", "id": "RmtLed", @@ -823,6 +833,15 @@ "ethClockGpio": 50, "ethClockExtIn": true } + }, + { + "type": "I2cScanModule", + "id": "I2cScan", + "parent_id": "System", + "controls": { + "sda": 7, + "scl": 8 + } } ] }, @@ -873,6 +892,15 @@ "ethClockGpio": 50, "ethClockExtIn": true } + }, + { + "type": "I2cScanModule", + "id": "I2cScan", + "parent_id": "System", + "controls": { + "sda": 7, + "scl": 8 + } } ] }, @@ -889,8 +917,14 @@ "WiFi" ], "planned": [ + "Audio", "Ethernet", - "Audio" + "Bluetooth", + "Thread/Zigbee", + "SD card", + "USB host", + "Speaker", + "CAN" ], "modules": [ { @@ -912,6 +946,25 @@ "type": "NetworkModule", "id": "Network", "controls": {} + }, + { + "type": "AudioModule", + "id": "Audio", + "parent_id": "System", + "controls": { + "wsPin": 55, + "sdPin": 54, + "sckPin": 53 + } + }, + { + "type": "I2cScanModule", + "id": "I2cScan", + "parent_id": "System", + "controls": { + "sda": 51, + "scl": 50 + } } ] } diff --git a/docs/moonmodules/core/Control.md b/docs/moonmodules/core/Control.md index eed36ef3..8b18cfd1 100644 --- a/docs/moonmodules/core/Control.md +++ b/docs/moonmodules/core/Control.md @@ -49,7 +49,7 @@ Control values persist via [FilesystemModule](FilesystemModule.md), which overla ## Prior art -### MoonLight — addControl ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonBase/Nodes.h#L80)) +### MoonLight — addControl ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonBase/Nodes.h#L80)) Binds via `reinterpret_cast(&variable)`; UI types "slider"/"select"/"toggle"/"text"/"display". diff --git a/docs/moonmodules/core/DevicesModule.md b/docs/moonmodules/core/DevicesModule.md index c7899759..41a7f5b5 100644 --- a/docs/moonmodules/core/DevicesModule.md +++ b/docs/moonmodules/core/DevicesModule.md @@ -1,64 +1,78 @@ # DevicesModule +![DevicesModule controls](../../assets/screenshots/Devices%20module.png) + A **core**, domain-neutral module that discovers other devices on the LAN, identifies what each is, and presents them as a browsable list. It focuses on *all* devices on the network (including this one, marked as self), not on the host's own state — so its card looks the same on every projectMM instance, ESP32 or PC. Light-domain modules consume the device list; the discovery machinery itself stays domain-neutral. Submodule of [NetworkModule](NetworkModule.md) — discovery depends on the network being up, the same placement reasoning as [ImprovProvisioningModule](ImprovProvisioningModule.md). Wired by code in `main.cpp` (`networkModule->addChild(devicesModule)`), marked `markWiredByCode()` so persistence preserves it. ## Controls -- `scan` — a **button** (momentary action, [ControlType::Button](Control.md)): pressing it re-runs the subnet sweep now (`onUpdate` → `restartScan`). A button, not a toggle, because it's a one-shot action, not an on/off state. -- `progress` — a progress bar of the sweep position (host 0..254, where 0 is idle/empty and 1..254 track the sweep). Always present (the WebSocket state push patches control *values* but not *structure*, so a hide-while-idle flag would not update live); at rest its value is 0 (empty bar). +- `devices` — a [List control](Control.md) whose rows are the discovered devices; each row expands to a detail panel. This module is the list's `ListSource`, walking its own `devices_` array (no copy, no allocation). Read-only from the browser (discovery output flows device → `/api/state` → browser), but **persistable** (see Persistence). + +Discovery state ("idle", "N devices", "N devices (cached)") is reported through the standard [MoonModule](MoonModule.md) `setStatus()` channel (rendered generically as the card's status line), not as a separate control. There is no scan button — devices announce themselves; nothing is polled. + +## Discovery (UDP presence, passive) + +Discovery is **passive UDP**: each device **broadcasts** a small presence packet on a well-known port, and this module **listens** (a bound `UdpSocket`, drained non-blocking each tick). No subnet sweep, no per-host probe, **no mDNS query** — a device appears when its broadcast arrives and ages out when it stops. + +- **Broadcast.** Every ~10 s (`kBroadcastEverySec`) this module broadcasts a **44-byte WLED-compatible presence packet** (see [`WledPacket`](../../../src/core/WledPacket.h)) to `255.255.255.255:65506`: `token=255, id=1`, our IP, deviceName, board-type byte — plus a projectMM marker stamped into the version field (a region no WLED validator reads). So a peer projectMM device recognises us, **and** a real WLED / WLED app browsing 65506 lists us too. Discovery-only: a WLED that receives it shows us in its instances list, it does **not** sync to it (sync/control is a separate WLED protocol on a port WLED never shares). +- **Listen.** Each `loop1s` tick drains the bound listener with non-blocking `recvFrom` (bounded per tick) and classifies each datagram through the plugins. Never blocks the tick — the hot-path-safe replacement for the former mDNS query, which destabilised our own advertise (a PTR query for a service we also host exhausts the IDF mDNS pool — see the [discovery-transport lesson](../../history/decisions.md)). + +**mDNS is advertise-only.** `mdnsInit` still announces `_http._tcp`+`mm=1` and `_wled._tcp`+`mac=` so the **native WLED app + Home Assistant discover us** (they only browse mDNS — UDP can't replace that). But this module never *queries* mDNS; all discovery is UDP. + +### Plugins (the interop seam) + +Foreign ecosystems hook in as **plugins**, not hardcoded branches — the adapter pattern (cf. `ListSource`, `ModuleFactory`). A [`DevicePlugin`](../../../src/core/DevicePlugin.h) declares the UDP port it listens on (`discoveryPort()`) and turns a received datagram into a `Device` kind (`classifyPacket`): -Sweep state ("idle", "scanning A.B.C.0/24", "N devices", "no network") is reported through the standard [MoonModule](MoonModule.md) `setStatus()` channel (rendered generically as the card's status line by HttpServerModule), not as a separate control. -- `devices` — a [List control](Control.md) whose rows are the discovered devices; each row expands to a detail panel. This module is the list's `ListSource`, walking its own `devices_` array (no copy, no allocation). +| Plugin | Claims (on UDP 65506) | Classifies as | +|---|---|---| +| `MmPlugin` | a valid WLED packet **with** the projectMM marker | projectMM | +| `WledPlugin` | a valid WLED packet **without** the marker | WLED | -## Discovery +`MmPlugin` is offered each packet first, so a projectMM peer (which broadcasts a marked, WLED-valid packet) is typed projectMM and not double-claimed as WLED. A new system is **one new plugin file** listed in the module — no core edit. The seam keeps `DiscoveredDevice` plain so a future hub plugin (Hue) extends it without reshaping the flat case; the (reserved) `command()` half translates a generic command into a system's protocol when a control consumer exists. *Concrete first, abstract later.* -Two strategies run side by side and merge into the *same* `devices_` list: +The plugin classification is pure and host-unit-tested (`unit_DeviceIdentify.cpp` feeds synthetic packets, incl. short/garbage → declined), with no network. The full pipeline is tested via `injectPacketForTest` (`unit_DevicesModule_discovery.cpp`) — and because `UdpSocket` works on desktop, the discovery path is host-testable with real datagrams, not just stubs. -**mDNS browse** is the push-style strategy and runs **every tick**. `stepMdns()` cycles round-robin through a small set of service types (`_http._tcp`, `_wled._tcp` today) using the non-blocking `platform::mdnsBrowseStart` / `mdnsBrowsePoll` / `mdnsBrowseStop` cycle: start one async PTR query, poll it (a 0 ms async check — never blocks the render loop), merge any resolved hosts via `upsertMdns`, then advance to the next service type. The service type maps to a `DevType` (`_wled._tcp` → WLED; `_http._tcp` → generic, refined later by the HTTP probe). Because it never blocks, it is the only strategy safe to run continuously, so it picks up advertisers (WLED, projectMM, anything advertising `_http._tcp`) as they come and go. +### Age-out -**Subnet sweep** is the fallback for hosts that don't advertise a useful service (a projectMM **desktop** instance, a generic web host). `restartScan()` captures the local IP (from `platform::ethGetIPv4` / `wifiStaGetIPv4`) and walks the local /24 (`subnet.1` .. `subnet.254`), one IP per `loop1s()` tick. The sweep runs **once at boot** (when the network first comes up) and otherwise only on a `scan` button press — there is **no periodic background sweep**. Reason: the probe is a *blocking* `httpGet` running on the render task, so each probe stalls the tick up to the probe timeout (~150 ms); a continuous background sweep would flicker the LEDs. At boot the LEDs aren't yet critical, so the one-shot sweep there is acceptable. +Each sighting stamps the device's `lastSeenMs`; `ageOut()` runs every tick. A live-confirmed device is kept for `kStaleMs` (**24 h**) after its last presence packet, so the list is a durable "devices I've seen" history; a **cached** row (restored from persistence, not yet re-heard this session) gets only a short `kCachedGraceMs` (**60 s**) probation, so a long-gone persisted device can't survive forever across reboots — a live packet promotes it to the 24 h window. A **timestamp**, not a counter. The self row never ages out (it tracks the current local IP). Storage is a fixed `devices_[kMaxDevices]` array — bounded, no heap. -Per host, `probe()` issues `platform::httpGet` (short timeout) and classifies the response: +## Interop — projectMM shows up in WLED -| Probe | Match | Type | -|-------|-------|------| -| `GET /api/state` | **HTTP 200** and body contains `"modules"` | projectMM | -| `GET /json/info` | **HTTP 200** and body contains `WLED` | WLED | -| (any other live host) | answered (any status), not the above | generic | -| no response on 80 or 8080 | — | not listed | +Because the presence broadcast and the mDNS advertise are WLED-shaped, a projectMM device appears in the WLED ecosystem two independent ways, with no projectMM software on the other side: -The status-200 gate matters: a 404/500 error page that happens to contain `"modules"` or `WLED` must not be misread as a real device. A non-200 response still proves the host is *alive*, so it falls through to the generic classification rather than being dropped. +**In WLED's own "Sync interfaces" instances list** — a real WLED lists every projectMM board it heard on UDP 65506. (The `undefined` columns are WLED-sync fields projectMM doesn't fill — the presence packet carries identity, not the full WLED sync state; listing is what we're after.) -Both ports 80 and 8080 are probed per host: ESP32 devices and WLED serve on 80, a projectMM **desktop** instance serves its API on 8080. A live host on 80 stops there; the 8080 attempt only adds a second timeout on otherwise-empty IPs. +![projectMM devices in WLED's instances list](../../assets/screenshots/Wled%20discovers%20projectMM.png) -The display name comes from the probe body — projectMM's `deviceName` (`/api/state`), WLED's `name` (`/json/info`) — falling back to the dotted-quad IP. A foreign device's reply is parsed with a local, defensive string scan (`extractStringAfter`), not the project's own [JsonUtil](Control.md) key parser: any input is tolerated (a garbage body yields an empty name), per the robustness contract for network-sourced data. +**In the native WLED app** (iOS / Android) — discovered via the mDNS `_wled._tcp` advertise, validated via the `/json/info` shim, with live colour + a working brightness slider over the `/ws` WebSocket. See [HttpServerModule § WLED-compatibility shim](HttpServerModule.md#wled-compatibility-shim) for the wire contract (reverse-engineered from the [WLED-Android](https://github.com/Moustachauve/WLED-Android) client). -### Discovery is per-protocol (HTTP is strategy one) +![projectMM devices in the native WLED app](../../assets/screenshots/WLED%20Native%20discovers%20projectMM.jpeg) -HTTP and mDNS find web-UI / service-advertising devices, but the wider ecosystem this module will talk to is found and addressed over **other** protocols too: Art-Net / sACN nodes and DDP devices (UDP, no web UI), OSC, RTP-MIDI (mDNS `_apple-midi._udp`). Discovery is therefore structured as **probe strategies** that each contribute to the *same* device list. Two bitmasks on every `Device` keep this open without reshaping the record: `speaks` (which protocols a device talks — `ProtoHttp` today; `ProtoArtnet`, `ProtoDdp`, … as strategies are added) so a consumer (Art-Net sync, fleet OTA) knows *how* to reach it, and `via` (which strategies found it — `scan` / `mdns` / `udp`) so the UI shows the discovery source. A device found by both mDNS and the sweep OR-s both bits — it is the same device, surfaced twice. Adding an mDNS service type is one entry in `kMdnsServices` (Home Assistant `_home-assistant._tcp`, ESPHome `_esphome._tcp`, …); adding a non-HTTP strategy is a new probe plus the bits it sets — neither reshapes the record or the wire format. +## Transport boundary (discovery vs commands) -Each sighting (any strategy) stamps the device's `lastSeenMs`; `ageOut()` runs every tick and drops a non-self device unseen for `kStaleMs` (24 h). A **timestamp**, not a per-sweep miss-counter, because the strategies run on different cadences (a minutes-long HTTP sweep, a seconds-long mDNS lap, a future async UDP beacon) with no shared sweep boundary to count against; "last seen at T" is true regardless of which strategy saw it. The window is a full day on purpose: mDNS re-confirms its devices cheaply every browse lap, but an HTTP-scan-only device (a PC instance, a generic host) has no cheap recurring refresh — the sweep is boot-once + manual, not periodic — so a short timeout would wrongly drop a still-alive device and force a re-scan. A day lets such a device persist on its single sighting while a genuinely-departed device still clears within a day. The self entry never ages out (restamped to "now" every sweep step). Storage is a fixed `devices_[kMaxDevices]` array — bounded, no heap. +Discovery is UDP presence (above) — lossy-OK, never device-to-device *commands*. Those split by need: must-arrive config (set brightness, presets, OTA) rides **REST** (`/api/control`, TCP-guaranteed); latency-critical lossy-OK traffic (time sync, live pixels) rides its own **UDP** stream (NetworkSend/Receive). This module does *discovery*; consumers reach a found device over the right transport for the job. ## Persistence (instant boot list) -The discovered list survives reboot: the `devices` [List control](Control.md) is persistable, so a completed sweep marks the module dirty and FilesystemModule saves the list as a JSON array; on boot the persistence overlay restores it (via `ListSource::restoreList`, which uses the recursive [JsonUtil](Control.md) reader's `forEachListElement` to walk the saved array) *before* the first sweep runs. So the UI shows the **last-known devices immediately** ("N devices (cached)") rather than waiting the minutes a fresh sweep takes — the real win for slow-to-find devices (a PC instance, a generic host) that aren't mDNS-discoverable. The self entry is not restored from the cache (its IP can change); `upsertSelf` re-adds it live with the current address. +The discovered list survives reboot: the `devices` [List control](Control.md) is persistable, so a change marks the module dirty and FilesystemModule saves the list as a JSON array; on boot the persistence overlay restores it (via `ListSource::restoreList`, which uses the recursive [JsonUtil](Control.md) reader's `forEachListElement`) *before* the first announcement arrives. So the UI shows the **last-known devices immediately** ("N devices (cached)") rather than waiting for the first re-announcement. The self entry is not restored from the cache (its IP can change); `upsertSelf` re-adds it live with the current address. The restore tolerates an old persisted file carrying extra keys (e.g. the former `via`/`speaks`) — the keyed reader ignores them. ## Self -This device always appears in the list (`upsertSelf`, marked `self:true`), so the card shows the whole network including the host. The UI marks the self row distinctly (an accent edge). The self entry is identified by comparing each probed IP to the local IP. +This device always appears in the list (`upsertSelf`, marked `self:true`), so the card shows the whole network including the host. The UI marks the self row distinctly (an accent edge). The self entry is identified by comparing the announcing IP to the local IP. ## Wire shape -The `devices` List serializes (via [Control](Control.md)'s `ControlType::List`) as a `value` array of row summaries — `{"name","ip","type",["self"]}` — with a parallel `detail` array carrying `url`, the `speaks` protocol array, the `via` discovery-source array, and `ageSec` (seconds since last seen, computed device-side as `now − lastSeenMs`; omitted on the self row, which is always current). A device restored from persistence but **not yet re-seen live** this session carries `cached:true` instead of `ageSec` (its `via` is empty and its timestamp is only the boot stamp, so a real "age" would be misleading) — the UI shows "last seen: cached" until a strategy re-confirms it, at which point `cached` clears and a real `ageSec` + `via` appear. The UI renders `ageSec` as a relative "last seen 2m ago". The List is read-only from the browser's side (discovery output flows device → `/api/state` → browser) but **persistable**: the saved array is parsed back on boot by `restoreList` to seed the instant cached list (see Persistence above). +The `devices` List serializes (via [Control](Control.md)'s `ControlType::List`) as a `value` array of row summaries — `{"name","ip","type",["self"]}` — with a parallel `detail` array carrying `url` and `ageSec` (seconds since last heard, computed device-side as `now − lastSeenMs`; omitted on the self row, which is always current). A device restored from persistence but **not yet re-heard live** this session carries `cached:true` instead of `ageSec` — the UI shows "last seen: cached" until an announcement re-confirms it, at which point `cached` clears and a real `ageSec` appears. The UI renders `ageSec` as a relative "last seen 2m ago". ## Prior art -- **MoonLight** uses UDP broadcast for device presence; DevicesModule takes the "devices find each other" idea but uses an IP-scan + REST-identify outer loop so non-broadcasting devices (a PC instance, Home Assistant, WLED) are found too. -- **WLED** discovery / the WLED JSON API (`/json/info`, `brand:WLED`) — the WLED identify probe and name field come from here. +- **mDNS-SD / DNS-SD (Bonjour, Avahi)** — the industry-standard service-discovery pattern this module uses: announce a service, browse for it. WLED, ESPHome, Home Assistant, Hue all speak it. +- **WLED** — the `_wled._tcp` service it advertises (and that the native WLED iOS/Android/Desktop apps browse) is the interop target the `WledPlugin` + the `_wled._tcp` advertise serve. +- **MoonLight** ([`ModuleDevices.h`](https://github.com/ewowi/MoonLight/blob/main/src/MoonBase/Modules/ModuleDevices.h)) uses a UDP presence broadcast for device discovery; DevicesModule carries that idea forward — the 44-byte WLED-compatible packet on UDP 65506 (see [`WledPacket`](../../../src/core/WledPacket.h)), written fresh against our architecture. mDNS stays advertise-only, for the foreign apps that discover *us* over it (the WLED native app, Home Assistant). - The web installer's `docs/install/devices.js` "Your devices" list is the prior art for the device record shape (name / url / type). ## Source -[DevicesModule.h](../../../src/core/DevicesModule.h) +[DevicesModule.h](../../../src/core/DevicesModule.h) · [DevicePlugin.h](../../../src/core/DevicePlugin.h) diff --git a/docs/moonmodules/core/HttpServerModule.md b/docs/moonmodules/core/HttpServerModule.md index d9b6de93..f9769199 100644 --- a/docs/moonmodules/core/HttpServerModule.md +++ b/docs/moonmodules/core/HttpServerModule.md @@ -60,6 +60,17 @@ All JSON responses stream through a `JsonSink` — no fixed-buffer ceiling, so a Both paths are domain-neutral (the server doesn't interpret the bytes). The resumable drain runs on **`loop20ms` (the 20 ms transport-poll), deliberately NOT the per-render-tick `loop()`** — pushing preview bytes to the socket must not be charged to the LED render hot path. The LED path (the driver output) is never delayed by the preview; the preview frame rate is instead bounded by the 20 ms drain cadence (a few fps at large full-res frames, higher for small grids), which is the right trade since the preview is a *view* and the LEDs are not. The resumable path lets a 128²+ full-res frame stream on a slow link without stalling the device: the effective frame rate self-limits (the next frame waits for `bufferedSendIdle()`), so the link sheds frame rate gracefully instead of freezing. When the two-core render/transport split lands ([architecture.md § Parallelism](../../architecture.md#parallelism)) the drain moves to the transport core and the cadence limit lifts — `loop20ms` is already that seam. +## WLED-compatibility shim + +A small set of WLED-shaped messages make a projectMM device appear in — and be controlled from — the **native WLED apps** (iOS / Android) and Home Assistant's WLED integration. The flow, reverse-engineered from the WLED-Android client (`DeviceDiscovery.kt`, `DeviceFirstContactService.kt`, `WebsocketClient.kt`): + +1. **Discover** over mDNS `_wled._tcp` (advertised by the platform). The app resolves the service to an IP — no TXT field is required. +2. **Validate** with `GET /json/info`, parsing it into its `Info` Moshi model. The model's **non-nullable** fields — `name`, `leds` (object), `wifi` (object) — gate acceptance: a missing one fails the parse and the device is silently dropped. An empty body `mac` is also rejected. So the served object is the minimal accepted one: `{name, mac, leds{}, wifi{}, brand:"WLED", product:"MoonModules"}`. `brand:"WLED"` is what the app keys on to accept it — we interoperate, not impersonate (`product` says what it is). This is **not** a full WLED emulation. +3. **Live state** comes over the **WebSocket** at `/ws`, NOT an HTTP GET — the app's `DeviceApi` has no state-GET; `WebsocketClient` parses each `/ws` text message as a `DeviceStateInfo` = `{state, info}`. So `pushWledStateToWebSockets()` pushes a `{state, info}` frame to every `/ws` client (alongside the module-state frame our own UI reads — each consumer keys on its own top-level shape and ignores the other). `state` is `{on, bri, seg:[{id:0, col:[[r,g,b]]}]}`: `on`/`bri` mirror the **Drivers** `brightness` control (off = 0); `col[0]` is the **live first-LED RGB**, so the app tints the device card with what the device is actually showing, falling back to projectMM **purple `[128,0,255]`** when the first LED is black/off. +4. **Control is bidirectional and goes over the same `/ws`.** The app's main list slider + toggle **SEND** state — a `{on?, bri?}` text frame — over the WebSocket (`sendState`), NOT via HTTP POST. So `pollWledStateFromWebSockets()` (on the `loop20ms` transport poll) reads each client's inbound frame, unmasks it (client→server frames are masked, RFC 6455 §5.3), and applies `{on, bri}` to the Drivers `brightness` control via the shared apply-core (`applySetControl`, the same path `/api/control` and Improv `APPLY_OP` use): `on:false` → 0; `on:true` with no `bri` → a visible default; `bri:N` → set N. Without this read the slider would snap back — we'd push our unchanged brightness on the next frame. The HTTP `POST /json/state` (used by the app's system quick-tiles + Home Assistant) shares the same `applyWledState` logic and echoes the resulting state; `GET /json/state` and `GET /json/si` are also served for direct HTTP clients. + +The colour read is the one place this core module reaches output state: `MoonModule::firstOutputRgb(uint8_t[3])` is a **domain-neutral virtual** (core declares it returning false; the light-domain `Drivers` overrides it to read pixel 0 of whichever buffer it is driving). This keeps HttpServerModule free of any light-domain include — the same boundary the preview path holds. + ## Cross-domain wiring HttpServerModule is core infrastructure with **no** light-domain dependencies — no `PreviewFrame`, no light types, no light includes. It exposes the `BinaryBroadcaster` interface (the synchronous `beginBinaryFrame` / `pushBinaryFrame` / `endBinaryFrame`, the resumable `sendBufferedFrame` / `bufferedSendIdle` / `cancelBufferedSend`, and `clientGeneration`); the light-domain `PreviewDriver` holds a `BinaryBroadcaster*` and streams each frame's bytes through it. `main.cpp` wires `PreviewDriver`'s broadcaster to the HttpServerModule instance — the only file that knows both. The preview's point budget and wire format are PreviewDriver's concern, documented there. @@ -74,6 +85,10 @@ HTTP via cpp-httplib (PC) / ESPAsyncWebServer (ESP32). WebSocket on separate por Separate MoonModules for HTTP and WebSocket. projectMM combines them into one module. +### WLED native app — [WLED-Android](https://github.com/Moustachauve/WLED-Android) by Christophe Gagnier ([@Moustachauve](https://github.com/Moustachauve)) + +The WLED-compatibility shim's exact field requirements were reverse-engineered from this client's source: `DeviceDiscovery.kt` (mDNS `_wled._tcp` browse), `DeviceFirstContactService.kt` (the `/json/info` validation + non-empty `mac` check), the `Info`/`State` Moshi models (the non-nullable `name`/`leds`/`wifi` fields that gate acceptance), and `WebsocketClient.kt` (live state over `/ws`, the `sendState` control direction). Credit to @Moustachauve — knowing precisely what the app reads is why the shim is the minimal accepted object rather than a guessed full WLED emulation. + ## Source [HttpServerModule.cpp](../../../src/core/HttpServerModule.cpp) · [HttpServerModule.h](../../../src/core/HttpServerModule.h) diff --git a/docs/moonmodules/core/I2cScanModule.md b/docs/moonmodules/core/I2cScanModule.md new file mode 100644 index 00000000..92748034 --- /dev/null +++ b/docs/moonmodules/core/I2cScanModule.md @@ -0,0 +1,27 @@ +# I2cScanModule + +A **core**, domain-neutral diagnostic that scans an I2C bus and reports which device addresses ACK — the standard [`i2cdetect`](https://manpages.debian.org/i2c-tools/i2cdetect.8.en.html) operation, surfaced in the UI. It is the bring-up tool for any I2C peripheral (an audio codec, a sensor, a port expander): set the bus pins, press scan, read off the addresses present. Confirms wiring before a driver tries to talk to the device. + +Not auto-wired. Factory-registered like [AudioModule](AudioModule.md), so a board with an I2C bus adds it through `docs/install/deviceModels.json` (its `sda`/`scl` controls carry that board's bus pins) or the user adds it from the UI. + +## Controls + +- `sda` / `scl` — the bus pins ([Pin](Control.md) controls). Default to **GPIO21/22**, the Arduino-ESP32 core's conventional I2C pair, so the control pre-fills a sensible starting point on a classic ESP32 (the pins route through the GPIO matrix, so they're a convention, not fixed hardware). A board with a fixed bus overrides them in its catalog entry — e.g. the S31's `sda:51, scl:50`. +- `scan` — a **button** (momentary action, [Control.md](Control.md)): pressing it runs the scan now (`onUpdate` → the probe). A button, not a toggle, because it's a one-shot action. +- `result` — a read-only string of the 7-bit addresses found, space-separated hex (e.g. `0x18 0x3c`); empty when none answer. + +Scan state ("N devices found", "set sda + scl pins first") reports through the standard [MoonModule](MoonModule.md) `setStatus()` channel. + +## How it works + +The probe is `platform::i2cScan(sda, scl, out, maxOut)` (declared in `src/platform/platform.h`). That seam is self-contained: it opens a **temporary** I2C master bus on the given pins, probes every 7-bit address (`0x01`–`0x77`), writes the ACKing addresses into the caller's buffer, and tears the bus down. Opening its own short-lived bus (rather than borrowing one) means the scan never conflicts with a bus another driver owns — e.g. the ES8311 codec on the ESP32-S31 holds its own bus in `platform_esp32_es8311.cpp`; the scan probes the same pins independently between codec operations. + +On a target without an I2C bus (the inert stub: an I2C-less ESP32, or desktop) the seam returns `kI2cBusUnavailable`, so the scan reports "bus unavailable" rather than a misleading "0 devices found" — the 0 is reserved for a real scan where nothing ACKed. + +## Prior art + +The bus-scan-as-a-feature mirrors MoonLight's I2C scan diagnostic; the seam name and probe range follow the Linux `i2c-tools` `i2cdetect` convention. + +## Source + +[I2cScanModule.h](../../../src/core/I2cScanModule.h) diff --git a/docs/moonmodules/core/MoonModule.md b/docs/moonmodules/core/MoonModule.md index bac0cbe8..c15995a8 100644 --- a/docs/moonmodules/core/MoonModule.md +++ b/docs/moonmodules/core/MoonModule.md @@ -80,7 +80,7 @@ Conditional controls (e.g. fields only visible under a Select mode) are always b ## Prior art -### MoonLight — Node ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonBase/Nodes.h)) +### MoonLight — Node ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonBase/Nodes.h)) - Base ~29 bytes + vtable. Effects add only their control variables (uint8_t each). - No std::string members (uses `Char` fixed-size strings). diff --git a/docs/moonmodules/light/Buffer.md b/docs/moonmodules/light/Buffer.md index 9b7922a3..6f8e8c34 100644 --- a/docs/moonmodules/light/Buffer.md +++ b/docs/moonmodules/light/Buffer.md @@ -16,7 +16,7 @@ Semaphores are expensive (~150 bytes on ESP32), so prefer lock-free patterns: an ## Prior art -### MoonLight — VirtualLayer.virtualChannels ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Layers/VirtualLayer.h)) +### MoonLight — VirtualLayer.virtualChannels ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/VirtualLayer.h)) Raw `uint8_t*` buffer, sized by `channelsPerLight * nrOfLights`. Supports RGB, RGBW, and multi-channel DMX fixtures via LightsHeader offsets. diff --git a/docs/moonmodules/light/Drivers.md b/docs/moonmodules/light/Drivers.md index eb6447e9..ce91f3f2 100644 --- a/docs/moonmodules/light/Drivers.md +++ b/docs/moonmodules/light/Drivers.md @@ -29,9 +29,22 @@ The Drivers container owns the shared output-correction state and exposes two co The state lives on `Correction` (`src/light/drivers/Correction.h`): a brightness LUT, channel-order table, output channel count, derive-white flag. `Drivers::onUpdate` rebuilds it on a `brightness`/`lightPreset` change and hands each child a `const Correction*`. Every driver sees the same composited output; per-driver layer assignment (different drivers reading different layers) is a [backlog](../../backlog/README.md) item. +## Per-driver source window (`start` / `count`) + +A **window-aware output driver** reads the shared source buffer and outputs a contiguous slice of it — its *window*. `DriverBase` provides two controls for this via an opt-in `addWindowControls()` helper, used by the output drivers (the LED drivers and the network sink). It is **not** forced on every `DriverBase` subclass — a driver that outputs the whole buffer (e.g. PreviewDriver, which renders the full logical buffer for the UI) simply doesn't call it and has no `start`/`count`: + +| Control | Type | Description | +|---|---|---| +| `start` | uint16 | First source-buffer light this driver outputs. Default `0`. | +| `count` | uint16 | Number of lights to output from `start`. Default `0` = **to the end of the buffer**. The slice is `[start, start+count)`, clamped to the buffer (a `start` past the end yields an empty slice — the driver idles, no out-of-bounds read). | + +This makes light distribution **explicit and order-independent**: each driver names its own slice, so reordering drivers does not change which lights each outputs (it only changes tick order). It is the alternative to a "split the buffer by sibling order" model some controllers use — here the user (or catalog) says which slice goes where. + +The motivating case: an **onboard status LED** and a **main strip** as two driver instances on the same buffer — one with window `[0, 1)` (the single onboard LED on its own pin), the other with window `[1, …)` (the strip on its pin, starting one light in). Neither steals the other's lights. Within a driver's window, the LED drivers' `pins` / `ledsPerPin` distribute *that slice* across the pins; the network sink maps its slice onto `universe_start` (the protocol offset is separate from the buffer `start`). + ## Prior art -### MoonLight — PhysicalLayer ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Layers/PhysicalLayer.h)) +### MoonLight — PhysicalLayer ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/PhysicalLayer.h)) Owns `channelsD` (display buffer). `compositeLayers()` maps virtualChannels → channelsD. Parallelism via semaphore: driver signals completion, compositor writes. diff --git a/docs/moonmodules/light/EffectBase.md b/docs/moonmodules/light/EffectBase.md index 7910b1c2..45393631 100644 --- a/docs/moonmodules/light/EffectBase.md +++ b/docs/moonmodules/light/EffectBase.md @@ -20,7 +20,7 @@ Animation is driven by **elapsed millis**, not frame count. This ensures consist ## Prior art -### MoonLight — Node + VirtualLayer ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonBase/Nodes.h), [source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Layers/VirtualLayer.h)) +### MoonLight — Node + VirtualLayer ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonBase/Nodes.h), [source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/VirtualLayer.h)) - Effects access `layer->width()`, `layer->height()`, `layer->depth()` directly via the VirtualLayer pointer. No separate EffectBase. - Buffer access via `layer->virtualChannels` (raw byte array). diff --git a/docs/moonmodules/light/Layer.md b/docs/moonmodules/light/Layer.md index 243f3162..843a1349 100644 --- a/docs/moonmodules/light/Layer.md +++ b/docs/moonmodules/light/Layer.md @@ -61,7 +61,7 @@ Consider whether Layer itself can provide the rendering context (buffer, dims, e ## Prior art -### MoonLight — VirtualLayer ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Layers/VirtualLayer.h)) +### MoonLight — VirtualLayer ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/VirtualLayer.h)) - `startPct`/`endPct` as Coord3D percentages (0-100) of the total fixture. - `oneToOneMapping` flag for fast path. diff --git a/docs/moonmodules/light/Layers.md b/docs/moonmodules/light/Layers.md index c0eefe6e..ef475279 100644 --- a/docs/moonmodules/light/Layers.md +++ b/docs/moonmodules/light/Layers.md @@ -14,7 +14,7 @@ The container owns no buffer: each layer owns its own, and the Drivers container ## Prior art -### MoonLight — VirtualLayer / PhysicalLayer ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight)) +### MoonLight — VirtualLayer / PhysicalLayer ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight)) MoonLight's `PhysicalLayer` runs N `VirtualLayer`s and composites their buffers into the display channel. Same idea, different shape: Drivers (not Layers) does the compositing here. diff --git a/docs/moonmodules/light/LightConfig.md b/docs/moonmodules/light/LightConfig.md index 0977fbe8..be2cffaa 100644 --- a/docs/moonmodules/light/LightConfig.md +++ b/docs/moonmodules/light/LightConfig.md @@ -29,7 +29,7 @@ These operate on individual channel values, not on a struct. They live in core ( ## Prior art -### MoonLight — LightsHeader ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Layers/LightsHeader.h)) +### MoonLight — LightsHeader ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/LightsHeader.h)) 48-byte metadata struct: `channelsPerLight` (3-32), per-channel offsets for RGB, RGBW, RGBCCT, brightness, pan/tilt/zoom/rotate/gobo. One struct handles LEDs AND DMX fixtures. Sent to frontend for preview rendering. diff --git a/docs/moonmodules/light/MappingLUT.md b/docs/moonmodules/light/MappingLUT.md index d0e8fbd1..74d6b33c 100644 --- a/docs/moonmodules/light/MappingLUT.md +++ b/docs/moonmodules/light/MappingLUT.md @@ -42,7 +42,7 @@ Memory: `estimateBytes(logicalCount, maxDest)` returns the total allocation size ## Prior art -### MoonLight — PhysMap ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Layers/PhysMap.h)) +### MoonLight — PhysMap ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/PhysMap.h)) Memory-optimal union. 2 bytes (no-PSRAM) or 4 bytes (PSRAM). Map type stored IN each entry. `oneToOneMapping` and `allOneLight` fast path flags. `forEachLightIndex()` for 1:N iteration. projectMM renames `oneToOneMapping` → `setIdentity()` / `!hasLUT()` because "one-to-one" reads as covering all 1:1 mappings, but the table-free fast path applies only to the sequential identity case. diff --git a/docs/moonmodules/light/ModifierBase.md b/docs/moonmodules/light/ModifierBase.md index 7201236e..70e9e686 100644 --- a/docs/moonmodules/light/ModifierBase.md +++ b/docs/moonmodules/light/ModifierBase.md @@ -24,7 +24,7 @@ Most modifiers are **non-affine** (a mask is a predicate, a tile is modulo) and ## Prior art -The mapping bake is the textbook image-warping pattern (precompute a coordinate transform into a spatial LUT; build it by backward mapping so no output pixel is unfilled — [Forward and Backward Mapping for Computer Vision](https://towardsdatascience.com/forward-and-backward-mapping-for-computer-vision-833436e2472/)). Collapsing a **chain** of discrete pixel folds into one index table — instead of giving each node its own frame buffer as a PC node graph (TouchDesigner, shader graphs) would — is the MCU-memory synthesis credited to **MoonLight** ([M_MoonLight.h](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h), [VirtualLayer.cpp](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Layers/VirtualLayer.cpp)): `modifySize` / `modifyPosition` / `modifyXYZ` map to our `modifyLogicalSize` / `modifyLogical` / `modifyLive`, written fresh against our `MappingLUT`. +The mapping bake is the textbook image-warping pattern (precompute a coordinate transform into a spatial LUT; build it by backward mapping so no output pixel is unfilled — [Forward and Backward Mapping for Computer Vision](https://towardsdatascience.com/forward-and-backward-mapping-for-computer-vision-833436e2472/)). Collapsing a **chain** of discrete pixel folds into one index table — instead of giving each node its own frame buffer as a PC node graph (TouchDesigner, shader graphs) would — is the MCU-memory synthesis credited to **MoonLight** ([M_MoonLight.h](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h), [VirtualLayer.cpp](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/VirtualLayer.cpp)): `modifySize` / `modifyPosition` / `modifyXYZ` map to our `modifyLogicalSize` / `modifyLogical` / `modifyLive`, written fresh against our `MappingLUT`. ## Source diff --git a/docs/moonmodules/light/drivers/NetworkSendDriver.md b/docs/moonmodules/light/drivers/NetworkSendDriver.md index fe0f9a09..47dc3ccf 100644 --- a/docs/moonmodules/light/drivers/NetworkSendDriver.md +++ b/docs/moonmodules/light/drivers/NetworkSendDriver.md @@ -8,8 +8,8 @@ Streams the light buffer over UDP in one of three industry protocols, selected b - `protocol` (select: ArtNet / E1.31 / DDP, default ArtNet) — the wire protocol; the destination port follows it automatically (6454 / 5568 / 4048). Changing it re-targets the socket **live, no reboot** ([§ Live reconfiguration](../../../architecture.md#live-reconfiguration-every-change-applies-without-a-reboot)) — switch output protocol on a running device mid-show. - `ip` (IPv4, default 255.255.255.255) — destination address. The default is the limited-broadcast address, so a fresh sender reaches every receiver on the LAN with no IP to type; set a unicast address to target one device. Changing it re-binds live. E1.31 multicast is deliberately not implemented (see Interop below). -- `universe_start` (uint16_t, default 0) — first universe for ArtNet and E1.31; DDP is byte-addressed and ignores it. -- `light_count` (uint16_t, default 0 = the whole buffer) — how many lights this sink sends, from the start of the buffer. >0 sends only the first N, so one sink covers just its slice — e.g. drive some lights over LEDs and the rest over ArtNet, or run two senders for different ranges — and each frame packs and sends exactly the lights this sink owns. The slice begins at light 0. +- `universe_start` (uint16_t, default 0) — first universe for ArtNet and E1.31; DDP is byte-addressed and ignores it. This is the *protocol* offset (which universe the slice maps onto), distinct from the buffer `start` below. +- `start` / `count` — the shared source-buffer window (every driver has it; see [Drivers § Per-driver source window](../Drivers.md#per-driver-source-window-start--count)). This sink packs and sends exactly the lights `[start, start+count)` (count 0 = the whole buffer from `start`), so one sender covers just its slice — e.g. drive some lights over LEDs and the rest over ArtNet, or run two senders for different ranges. - `fps` (uint8_t, default 50, range 1-120) — frame rate limit. Without it the loop would re-send on every render tick; receivers expect a steady frame cadence. ## Chunking per protocol diff --git a/docs/moonmodules/light/drivers/PreviewDriver.md b/docs/moonmodules/light/drivers/PreviewDriver.md index 29284365..f4626fac 100644 --- a/docs/moonmodules/light/drivers/PreviewDriver.md +++ b/docs/moonmodules/light/drivers/PreviewDriver.md @@ -55,7 +55,7 @@ Positions are 1 byte per axis. A layout whose bounding box exceeds 255 on any ax ## Prior art -### MoonLight — PhysicalLayer + WebSocket ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Layers/PhysicalLayer.h)) +### MoonLight — PhysicalLayer + WebSocket ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/PhysicalLayer.h)) The model this implements: virtual(logical grid) → physical(sparse lights) via a mapping table; light **positions sent once** at mapping time (`monitorPass`, `packCoord3DInto3Bytes` = 1 byte/axis, `isPositions` header state), **channels streamed per frame**. 3D WebGL renderer in the frontend. diff --git a/docs/moonmodules/light/effects/GameOfLifeEffect.md b/docs/moonmodules/light/effects/GameOfLifeEffect.md index 6420a34c..38ed7595 100644 --- a/docs/moonmodules/light/effects/GameOfLifeEffect.md +++ b/docs/moonmodules/light/effects/GameOfLifeEffect.md @@ -59,7 +59,7 @@ line in place of `hsvToRgb`. Nothing else is coupled to either. - **MoonLight `E_MoonModules.h` GameOfLife** — the feature-rich origin (rulesets, palette colouring, blur, mutation, pentomino seeding, CRC stasis detection). We take the proven algorithm and re-seed idea, not the structure. - ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h), + ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h), Ewoud Wijma 2022 / Brandon Butler 2024). - **projectMM v1 — GameOfLifeEffect** ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/effects/GameOfLifeEffect.h)) — diff --git a/docs/moonmodules/light/effects/NetworkReceiveEffect.md b/docs/moonmodules/light/effects/NetworkReceiveEffect.md index e180cefa..dec06bd4 100644 --- a/docs/moonmodules/light/effects/NetworkReceiveEffect.md +++ b/docs/moonmodules/light/effects/NetworkReceiveEffect.md @@ -38,7 +38,7 @@ Live tier: `uv run scripts/scenario/run_network_live.py` ([MoonDeck.md § run_ne ## Prior art -### MoonLight — D_NetworkIn ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Drivers/D_NetworkIn.h)) +### MoonLight — D_NetworkIn ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Drivers/D_NetworkIn.h)) ArtNet/E1.31/DDP receive in one driver node (protocol selected by control; we autodetect by port instead). diff --git a/docs/moonmodules/light/effects/NoiseEffect.md b/docs/moonmodules/light/effects/NoiseEffect.md index 71188160..2b32921c 100644 --- a/docs/moonmodules/light/effects/NoiseEffect.md +++ b/docs/moonmodules/light/effects/NoiseEffect.md @@ -23,7 +23,7 @@ The effect picks the 2D (`depth == 1`) or 3D path per `loop()`. The noise value ## Prior art -### MoonLight — E_MoonLight.h ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h)) +### MoonLight — E_MoonLight.h ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h)) Multiple noise effects (Noise2D, Noise3D variants). Uses FastLED noise functions. Time via `millis()`. diff --git a/docs/moonmodules/light/effects/RipplesEffect.md b/docs/moonmodules/light/effects/RipplesEffect.md index a276267a..5ef2d96c 100644 --- a/docs/moonmodules/light/effects/RipplesEffect.md +++ b/docs/moonmodules/light/effects/RipplesEffect.md @@ -15,7 +15,7 @@ Genuinely 3D (`Dim::D3`): it writes a height across the y-axis. On a flat 2D lay ## Prior art -Ported from [MoonLight](https://github.com/MoonModules/MoonLight)'s Ripples (via projectMM-v1), studied and rewritten against this project's `EffectBase` — we read the approach and implemented our own, reusing `core/color.h`'s `hsvToRgb` rather than MoonLight's inlined HSV. The wavefront math (distance → phase → sine height, the `1.3·(255−interval)/128·√h` spacing and `millis/(100−speed)/6.4` time base) follows MoonLight's so the look matches. +Ported from [MoonLight](https://github.com/ewowi/MoonLight)'s Ripples (via projectMM-v1), studied and rewritten against this project's `EffectBase` — we read the approach and implemented our own, reusing `core/color.h`'s `hsvToRgb` rather than MoonLight's inlined HSV. The wavefront math (distance → phase → sine height, the `1.3·(255−interval)/128·√h` spacing and `millis/(100−speed)/6.4` time base) follows MoonLight's so the look matches. Float trig (`sinf`/`sqrtf`) in the loop is consistent with the existing wave effects (Plasma, LavaLamp); the hot-path integer-math preference is for per-light colour work, not the handful of transcendental ops a wavefront needs. diff --git a/docs/moonmodules/light/layouts/GridLayout.md b/docs/moonmodules/light/layouts/GridLayout.md index 66a75e3b..1abe202c 100644 --- a/docs/moonmodules/light/layouts/GridLayout.md +++ b/docs/moonmodules/light/layouts/GridLayout.md @@ -14,7 +14,7 @@ A plain grid (`serpentine` off) emits driver index `i` at box cell `i`, so the L ## Prior art -### MoonLight — L_MoonLight.h Panel ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h)) +### MoonLight — L_MoonLight.h Panel ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h)) Panel layout with serpentine, multiple panel arrangements. Uses `addLight()` to define each position. diff --git a/docs/moonmodules/light/layouts/SphereLayout.md b/docs/moonmodules/light/layouts/SphereLayout.md index 7fb6af7b..87ef07e6 100644 --- a/docs/moonmodules/light/layouts/SphereLayout.md +++ b/docs/moonmodules/light/layouts/SphereLayout.md @@ -18,7 +18,7 @@ A sphere is **not** 1:1 unshuffled — the shell points are sparse within the bo ## Prior art -### MoonLight / projectMM v1–v2 — layout nodes ([MoonLight L_MoonLight.h](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h)) +### MoonLight / projectMM v1–v2 — layout nodes ([MoonLight L_MoonLight.h](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h)) Prior projects expose layouts that call an `addLight(x, y, z)` per position; SphereLayout follows the same "a layout enumerates its light coordinates" shape, computing the shell analytically rather than from a stored list. diff --git a/docs/moonmodules/light/modifiers/CheckerboardModifier.md b/docs/moonmodules/light/modifiers/CheckerboardModifier.md index 1d2ad910..ee6395fb 100644 --- a/docs/moonmodules/light/modifiers/CheckerboardModifier.md +++ b/docs/moonmodules/light/modifiers/CheckerboardModifier.md @@ -29,7 +29,7 @@ A Layer folds all its enabled modifiers as a chain (Checkerboard-then-Multiply d ## Prior art -### MoonLight — M_MoonLight.h Checkerboard ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h)) +### MoonLight — M_MoonLight.h Checkerboard ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h)) MoonLight's Checkerboard drops lights by setting `position.x = UINT16_MAX` (a sentinel the layout pass skips), with `size`, `invert`, and a `group` flag. We express the drop as `modifyLogical` returning `false` (no sentinel needed) and start with `size` + `invert`; `group` is deferred. diff --git a/docs/moonmodules/light/modifiers/MultiplyModifier.md b/docs/moonmodules/light/modifiers/MultiplyModifier.md index 2249c399..96b3d661 100644 --- a/docs/moonmodules/light/modifiers/MultiplyModifier.md +++ b/docs/moonmodules/light/modifiers/MultiplyModifier.md @@ -36,7 +36,7 @@ A Layer folds all its enabled modifiers as a chain (order matters: multiply-then ## Prior art -### MoonLight — M_MoonLight.h Multiply ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h)) +### MoonLight — M_MoonLight.h Multiply ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h)) MoonLight's Multiply node tiles via `position % modifierSize` and reflects odd tiles when its `mirror` flag is set (`position = size − 1 − position`). We expose **per-axis** mirror bools and per-axis multipliers instead of MoonLight's single multiplier coord + single mirror flag, so X/Y/Z fold and tile independently. MoonLight keeps Mirror, Multiply, Transpose, and Kaleidoscope as separate nodes; we fold mirror into Multiply since mirror is just multiply-2-with-reflection. diff --git a/docs/reference/esp32-s31-coreboard.md b/docs/reference/esp32-s31-coreboard.md new file mode 100644 index 00000000..49d37dbe --- /dev/null +++ b/docs/reference/esp32-s31-coreboard.md @@ -0,0 +1,93 @@ +# ESP32-S31 Function-CoreBoard-1 — hardware reference + +Pin maps and onboard features for the Espressif **ESP32-S31 Function-CoreBoard-1**, read from the +official schematic so projectMM work (Ethernet, audio, SD, USB-host) reads this instead of +re-scraping the PDF. The board is the bench S31 (`esp32s31` firmware). + +**Sources** +- Schematic (rev C, 2026-05-13): +- PCB layout: +- Datasheet (chip): +- Module (WROOM-3): +- User guide: + +The module is **ESP32-S31-WROOM-3**: RISC-V dual-core (≤300 MHz), Wi-Fi 6, BT, IEEE 802.15.4, +on-chip 1 Gbps EMAC. It shares the MoonLive RISC-V backend with the ESP32-P4. + +## Audio (ES8311 codec) + +The onboard electret mic (J6) and speaker connect through an **ES8311 mono codec** (U7) + an +**NS4150B 3 W class-D amplifier** (U9). The ESP is the I2S master (it drives MCLK). + +| Signal | GPIO | Direction / role | +|---|---|---| +| I2S_MCLK | 52 | master clock → codec (ESP is I2S master; MCLK = 256 × sample_rate) | +| I2S_SCLK (BCLK) | 53 | bit clock | +| I2S_LRCK (WS) | 55 | word select | +| I2S_ASDOUT | 54 | **mic / ADC data: codec → ESP** (the record path) | +| I2S_DSDIN | 56 | playback / DAC data: ESP → codec (speaker path) | +| **ESP_I2C_SDA** | **51** | codec control bus — **SDA is GPIO51, SCL is GPIO50** | +| **ESP_I2C_SCL** | **50** | codec control bus | +| PA_CTRL | 57 | NS4150B amplifier enable | + +> **SDA/SCL are GPIO51/GPIO50** — the *opposite* of what the schematic's `ESP_I2C_SDA` / +> `ESP_I2C_SCL` net labels suggest. Bench-confirmed: the [I2cScanModule](../moonmodules/core/I2cScanModule.md) +> (sda=51, scl=50 in the S31 catalog entry) finds the ES8311 ACK at 0x18; with 50/51 nothing +> ACKs. The other audio pins match the schematic + the chip's GPIO table (all of GPIO50–57 are +> plain I/O GPIOs routed through the matrix — no special-function conflict). + +- **ES8311 I2C address: `0x18`** (the default; set by the `CE` pin tie). +- Driven by Espressif's **`esp_codec_dev`** managed component (the ES8311 driver). The codec + needs **MCLK running before it answers I2C**, and `es8311_codec_cfg.mclk_div` must be set + (256, the standard ratio) or `open` fails "unable to configure sample rate". So AudioModule + brings up the I2S channel (which drives MCLK on GPIO52) **before** the codec I2C config. +- **Mic-only path** (audio-reactive input) needs MCLK/SCLK/LRCK + ASDOUT (record) + the I2C bus. + The speaker path (DSDIN + PA_CTRL) is output, a separate capability. + +## Ethernet (YT8531 PHY, RGMII, 1 Gbps) + +On-chip EMAC → **YT8531** (Motorcomm) PHY (U8) → RJ45, **RGMII** with a 25 MHz crystal (Y2). + +| Signal | GPIO | | Signal | GPIO | +|---|---|---|---|---| +| ETH_INTN | 2 | | ETH_TXD3 | 10 | +| PHY_MDC | 4 | | ETH_TX_CTL | 11 | +| PHY_MDIO | 5 | | ETH_TXCLK | 13 | +| ETH_PHY_RST | 6 | | ETH_RX_CLK | 14 | +| ETH_TXD0 | 7 | | ETH_RX_CTL | 15 | +| ETH_TXD1 | 8 | | ETH_RXD3 | 16 | +| ETH_TXD2 | 9 | | ETH_RXD2 | 17 | +| | | | ETH_RXD1 | 18 | +| | | | ETH_RXD0 | 19 | + +> **RGMII, not RMII.** projectMM's classic/P4 Ethernet path (`ethInit` in +> `src/platform/esp32/platform_esp32.cpp`) is RMII (fewer data lines, 50 MHz ref clock). The S31's +> 1 Gbps EMAC is RGMII (4-bit data each way + TX/RX clocks). Wiring the S31 eth needs an RGMII MAC +> config branch — it is not a drop-in of the RMII pin struct. + +## Other onboard features + +- **Addressable RGB LED** — WS2812 (D7) on **GPIO60**. *(Driven today: the catalog's S31 + `RmtLedDriver` uses `pins: "60"`.)* +- **SD card slot** — SD_D0–D3 / SD_CLK / SD_CMD on the module's SDIO pins (GPIO20–25 per the user + guide). Note: `SOC_SDMMC_SUPPORTED` is **absent** in the S31 soc-caps, so the slot is likely + SPI-mode (GPSPI) rather than the SDMMC peripheral — confirm before relying on it. +- **USB-A host** — USB 2.0 high-speed host port (5 V, 0.5 A limit). +- **USB-C ×2** — one is USB Serial/JTAG (native), one is a USB-to-UART bridge (CP2102N default, or + CH9102X with the alternate BOM). Auto-download via DTR/RTS → EN/BOOT. +- **Buttons** — BOOT (GPIO61), RESET (EN). +- **40-pin GPIO header** (J2). Optional 32.768 kHz crystal footprint (Y1, NC by default). + +## SoC capabilities (from `components/soc/esp32s31/include/soc/soc_caps.h`) + +Wi-Fi 6 · Bluetooth (no separate BLE soc-flag) · IEEE 802.15.4 (Thread/Zigbee) · USB-OTG · GPSPI · +TWAI (CAN) · RMT · Parlio · LCD_CAM i80 · on-chip EMAC · PSRAM. RISC-V dual-core. + +The S31 catalog entry drives **LEDs** (RMT on GPIO60) and **Wi-Fi 6**, and wires an +**[I2cScanModule](../moonmodules/core/I2cScanModule.md)** on the codec bus (SDA 51 / SCL 50) for +I2C bring-up. The **[AudioModule](../moonmodules/core/AudioModule.md)** ES8311 path is implemented +— the codec seam configures the ES8311 over I2C (codec reachable, ACK at 0x18) and AudioModule +reads the I2S mic. End-to-end mic validation depends on confirming MCLK at GPIO52; the S31 entry +keeps **Audio** under `planned` until that bench check passes, so the installer advertises only +what's confirmed working. The other board capabilities (Ethernet, Bluetooth, SD, USB host, …) +likewise live in the entry's `planned` list — see the S31 entry in `docs/install/deviceModels.json`. diff --git a/docs/testing.md b/docs/testing.md index 1b781fac..3cb322a4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -11,7 +11,7 @@ Both are produced by `scripts/docs/generate_test_docs.py`; the source of truth i Three test categories, each with a clear purpose: -- **Unit tests** (desktop, `test/unit/{core,light}/unit_*.cpp`) — exercise individual MoonModules in isolation with doctest. Each test file declares `// @module ` so it's categorised under that module in the generated inventory. Run via `ctest` or `./build/test/mm_tests`. Verify a module's API, edge cases, and output independent of how it's wired into a pipeline. +- **Unit tests** (desktop, `test/unit/{core,light}/unit_*.cpp`) — exercise individual MoonModules in isolation with doctest. Each test file declares `// @module ` so it's categorised under that module in the generated inventory. Run via `ctest` or `./build//test/mm_tests` (`` = `macos`/`linux`/`windows`, the per-host build dir). Verify a module's API, edge cases, and output independent of how it's wired into a pipeline. - **In-process scenarios** (desktop, `test/scenarios/{core,light}/scenario_*.json`) — exercise the system as an integrated pipeline. Each scenario is a declarative JSON file with a sequence of steps (`add_module`, `set_control`, `measure`) and optional performance bounds. The scenario runner (`test/scenario_runner.cpp`) replays the steps in-process and reports tick + heap per `measure` step. Same JSON files run against a live device through the HTTP API — that's the next tier. - **Live scenarios** — the same scenarios driven against a running device over REST. See [Live scenarios](#live-scenarios) below. diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt index f2fa29b0..89bd16f7 100644 --- a/esp32/main/CMakeLists.txt +++ b/esp32/main/CMakeLists.txt @@ -16,12 +16,13 @@ idf_component_register( "../../src/platform/esp32/moonlive_lower_riscv.cpp" "../../src/platform/esp32/platform_esp32_fs.cpp" "../../src/platform/esp32/platform_esp32_ota.cpp" - "../../src/platform/esp32/platform_esp32_httpget.cpp" "../../src/platform/esp32/platform_esp32_improv.cpp" "../../src/platform/esp32/platform_esp32_rmt.cpp" "../../src/platform/esp32/platform_esp32_lcd.cpp" "../../src/platform/esp32/platform_esp32_parlio.cpp" "../../src/platform/esp32/platform_esp32_i2s.cpp" + "../../src/platform/esp32/platform_esp32_i2c.cpp" + "../../src/platform/esp32/platform_esp32_es8311.cpp" INCLUDE_DIRS "../../src" "../../src/platform/esp32" @@ -91,6 +92,16 @@ if(MM_NO_ETH) target_compile_definitions(${COMPONENT_LIB} PRIVATE MM_NO_ETH) endif() +# MM_MAX_GPIO — the highest valid GPIO for this chip, the default clamp for +# Control.h's Pin controls. Derived from the IDF's own per-chip soc-cap +# (CONFIG_SOC_GPIO_PIN_COUNT, exposed as a CMake var after `idf.py set-target`): +# count-1 is the max index (62 on the S31 → 61). Injecting it here keeps Control.h +# core (no platform/IDF include) while the value stays IDF-sourced, not hand-kept. +if(CONFIG_SOC_GPIO_PIN_COUNT) + math(EXPR MM_MAX_GPIO "${CONFIG_SOC_GPIO_PIN_COUNT} - 1") + target_compile_definitions(${COMPONENT_LIB} PRIVATE MM_MAX_GPIO=${MM_MAX_GPIO}) +endif() + # Embed UI files — regenerate on every build. ESP-IDF's bundled Python venv # is named `python.exe` on Windows and `python3` on macOS/Linux; bare `python3` # fails on the Windows IDF venv. find_package(Python3) lets CMake locate the diff --git a/esp32/main/idf_component.yml b/esp32/main/idf_component.yml index 6d4be444..59d46eba 100644 --- a/esp32/main/idf_component.yml +++ b/esp32/main/idf_component.yml @@ -37,6 +37,18 @@ dependencies: # only from platform_esp32_i2s.cpp's audioFft. espressif/esp-dsp: version: "^1.5.0" + # esp_codec_dev — Espressif's audio-codec driver library; we use its ES8311 part + # (es8311_codec_new) to configure the ESP32-S31 CoreBoard's onboard mic, which is + # an analog mic behind an ES8311 I2S codec (I2C addr 0x18) rather than a direct + # I2S MEMS mic. Referenced only from platform_esp32_es8311.cpp, behind the codec + # gate. The `rules` gate scopes it to the S31 — the only board with the codec — + # so other targets don't resolve a component they never compile (same pattern as + # ip101/w5500 above). A managed component outside mainline v6.0, like the P4 + # esp_hosted exception (docs/building.md § ESP-IDF version). + espressif/esp_codec_dev: + version: "^1.3.0" + rules: + - if: "target == esp32s31" # esp_wifi_remote + esp_hosted — WiFi for the ESP32-P4 via the on-board ESP32-C6 # co-processor over SDIO. The P4 has no native radio; these present the C6's # radio through the standard esp_wifi_* API (API-compatible, so the WiFi seam in diff --git a/scripts/MoonDeck.md b/scripts/MoonDeck.md index 1a06bf3a..600f5365 100644 --- a/scripts/MoonDeck.md +++ b/scripts/MoonDeck.md @@ -41,7 +41,7 @@ Run the desktop test suite. uv run scripts/test/test_desktop.py ``` -Runs `./build/test/mm_tests -s` (doctest with all test cases shown). +Runs `./build//test/mm_tests -s` (doctest with all test cases shown) — same per-host build dir as the desktop build above. ### run_desktop diff --git a/scripts/docs/screenshot_modules.py b/scripts/docs/screenshot_modules.py index dca9c511..309a2a56 100644 --- a/scripts/docs/screenshot_modules.py +++ b/scripts/docs/screenshot_modules.py @@ -47,8 +47,8 @@ and repo transfers — if it's "missing" it was simply never installed here. Running server: the script captures against whatever is on --host (default -:8080). Start ONE fresh server: cmake --build build -j && ./build/projectMM -(or via MoonDeck's PC tab). GOTCHA: a leftover binary on :8080 captures the WRONG +:8080). Start ONE fresh server: uv run scripts/build/build_desktop.py && ./build//projectMM +(or via MoonDeck's PC tab — same per-host build dir, so they share the binary). GOTCHA: a leftover binary on :8080 captures the WRONG images silently — e.g. a `build/macos/projectMM` from a MoonDeck run still bound to the port serves the OLD code, so a renamed/changed effect screenshots as its previous version no matter how often you rebuild. The script now prints a STALE @@ -359,9 +359,9 @@ def check_server_freshness(host: str) -> None: f"source registers: {', '.join(missing[:6])}" + (" …" if len(missing) > 6 else "")) print(f" The running binary is older than the current source. Most likely a") - print(f" second projectMM (e.g. build/macos/projectMM from MoonDeck) is still") - print(f" on :8080. Fix: pkill -f projectMM then rebuild + run ONE server:") - print(f" cmake --build build -j && ./build/projectMM") + print(f" second projectMM (a stale build//projectMM) is still on :8080.") + print(f" Fix: pkill -f projectMM then rebuild + run ONE server:") + print(f" uv run scripts/build/build_desktop.py && ./build//projectMM") # --------------------------------------------------------------------------- diff --git a/scripts/rename/rename_to_moonlight.py b/scripts/rename/rename_to_moonlight.py new file mode 100644 index 00000000..7540e6ce --- /dev/null +++ b/scripts/rename/rename_to_moonlight.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Mechanical projectMM -> MoonLight rename sweep (dry-run by default). + +The one-shot transition tool for [docs/backlog/rename-to-moonlight.md] Phase 1 +step 4. Replaces the product-name token everywhere it is the *current* name, +while leaving the references that must NOT change: the predecessor "MoonLight" +prose/links (already repointed to ewowi/MoonLight), the MoonLive scripting +engine (a different name), and the docs/history era record (which keeps saying +projectMM by the present-tense history exception). + +Run it NOW against today's tree to harden the exclude list and review the diff, +but DRY-RUN ONLY until the switch — the externally-visible identifiers (binary +name, mDNS prefix, repo URL, library.json name) must flip *at* the switch, lined +up with the first release under the new repo (see the plan's Phase 2/3). + +ORDERING: the sweep also rewrites the repo URL (MoonModules/projectMM -> +MoonModules/MoonLight) and the docs host (moonmodules.org/projectMM). Per the +plan that is correct ONLY when run *after* the repo rename (Phase 3.2): the URL +becomes MoonModules/MoonLight, which only resolves once this repo holds that +name. So --apply belongs in Phase 3.3, after 3.2 — not before. + + uv run scripts/rename/rename_to_moonlight.py # dry-run: list every hit, change nothing + uv run scripts/rename/rename_to_moonlight.py --apply # WRITE the changes (switch-day only) + +Why a script and not a bare `sed`: the value here is not clever per-form logic +(a plain token replace is correct for every form — repo URL, host path, +binary basename, product name, deviceName slug all just contain the token), it +is the EXCLUDE LIST + the dry-run review + the binary/.git guards. Verified +against today's tree: `projectMM` is never a substring of another token, and the +rename touches neither `MoonLight` nor `MoonLive`, so those are safe by +construction — the only deliberate handling is the capitalised enum token +`ProjectMM` and the path exclusions below. + +Exit 1 if a dry-run finds hits (so CI / a pre-switch check can assert "still +references to flip"); exit 0 when clean (post-switch, nothing left). +""" + +import argparse +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent + +# Ordered token replacements. `ProjectMM` (the DevType enum) before `projectMM` +# so the capitalised form is handled as its own token, not left half-rewritten. +# Both map to the same product name; the longer forms (MoonModules/projectMM, +# projectMM.bin, moonmodules.org/projectMM) fall out of replacing the token +# inside them — no per-form rules needed. +REPLACEMENTS = [ + ("ProjectMM", "MoonLight"), # DevType::ProjectMM enum. Safe: classification keys + # on the "modules" marker (DeviceIdentify.h), not this + # token — devTypeStr's "projectMM" is only a UI label. + ("projectMM", "MoonLight"), # the product name in every other form +] + +# The file list comes from `git ls-files` (tracked files only), so build output +# (build/, esp32/build/ — both gitignored) and other artifacts never enter the +# sweep, without maintaining a brittle path blocklist. Only these directory +# prefixes are then *additionally* excluded for content reasons: +EXCLUDE_DIRS = [ + "docs/history", # records the projectMM ERA by name — present-tense + # history exception (CLAUDE.md § Principles) +] +EXCLUDE_FILES = [ + # This plan describes BOTH names and the move between them; rewriting it would + # corrupt its meaning ("the predecessor at MoonModules/MoonLight vacates…"). + "docs/backlog/rename-to-moonlight.md", + # The rename script itself (it names the tokens it replaces). + "scripts/rename/rename_to_moonlight.py", +] + +# File extensions the sweep considers (text formats that carry the name). Binary +# assets (.png/.jpg/.bin) are skipped both by extension and by the text-read +# guard below. +INCLUDE_SUFFIXES = { + ".md", ".py", ".h", ".cpp", ".js", ".json", ".html", ".css", + ".txt", ".csv", ".cmake", ".yml", ".yaml", ".ini", +} +# Files with no suffix that still matter (e.g. CMakeLists.txt handled via suffix; +# add bare names here if needed). CMakeLists.txt has a .txt-like role but no +# suffix match, so include it explicitly by name. +INCLUDE_NAMES = {"CMakeLists.txt"} + + +def is_excluded(rel: str) -> bool: + for d in EXCLUDE_DIRS: + if rel == d or rel.startswith(d + "/"): + return True + return rel in EXCLUDE_FILES + + +def wanted(path: Path) -> bool: + if path.suffix in INCLUDE_SUFFIXES: + return True + return path.name in INCLUDE_NAMES + + +def iter_files(): + # Tracked files only — `git ls-files` respects .gitignore, so build output + # (build/, esp32/build/) is never swept and we don't maintain a blocklist. + # Caveat: gitignored files are therefore NOT covered — notably the private + # bench registry scripts/moondeck.json, whose "board" names must be + # hand-updated at switch-time (see the plan's Phase 1 step 5). + out = subprocess.run( + ["git", "ls-files"], cwd=ROOT, capture_output=True, text=True, check=True + ).stdout + for rel in sorted(out.splitlines()): + if not rel or is_excluded(rel): + continue + path = ROOT / rel + if not path.is_file() or not wanted(path): + continue + yield path + + +def count_hits(text: str) -> int: + return sum(text.count(old) for old, _ in REPLACEMENTS) + + +def apply_replacements(text: str) -> str: + for old, new in REPLACEMENTS: + text = text.replace(old, new) + return text + + +def main() -> int: + ap = argparse.ArgumentParser(description="projectMM -> MoonLight rename sweep") + ap.add_argument("--apply", action="store_true", + help="WRITE the changes (default is a dry-run that changes nothing)") + args = ap.parse_args() + + total_hits = 0 + touched = 0 + for path in iter_files(): + try: + text = path.read_text(encoding="utf-8") + except (UnicodeDecodeError, ValueError): + continue # binary / non-utf8 — the text guard, belt-and-braces with the suffix filter + hits = count_hits(text) + if hits == 0: + continue + total_hits += hits + touched += 1 + rel = path.relative_to(ROOT).as_posix() + if args.apply: + path.write_text(apply_replacements(text), encoding="utf-8") + print(f" rewrote {rel} ({hits} hit{'s' if hits != 1 else ''})") + else: + print(f" {rel}: {hits} hit{'s' if hits != 1 else ''}") + # show each line so the dry-run is reviewable + for n, line in enumerate(text.splitlines(), 1): + if any(old in line for old, _ in REPLACEMENTS): + print(f" {n}: {line.strip()[:100]}") + + mode = "APPLIED" if args.apply else "DRY-RUN" + print(f"\n[{mode}] {total_hits} hit(s) across {touched} file(s).") + if args.apply: + print("Changes written. Run the full gate set (build all ESP32 variants, " + "ctest, scenarios, check_devices, check_specs) before committing.") + return 0 + # Dry-run: non-zero when there is still something to flip, so a pre-switch + # check can assert the work isn't done; zero post-switch when clean. + return 1 if total_hits else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/core/AudioModule.h b/src/core/AudioModule.h index d4c36396..602761a0 100644 --- a/src/core/AudioModule.h +++ b/src/core/AudioModule.h @@ -219,13 +219,29 @@ class AudioModule : public MoonModule { setStatus("mic: set wsPin / sdPin / sckPin", Severity::Status); return; } + // Bring up the I2S channel FIRST. On a codec board (an analog mic behind an + // I2S codec, e.g. the S31's ES8311) the I2S peripheral drives MCLK, and the + // codec won't even answer I2C until that clock runs — so I2S precedes the + // codec config. The MCLK pin comes from the per-target codec config + // (platform::audioCodecPins.mclk); −1 on a direct MEMS mic (self-clocked). + const int16_t mclkPin = platform::audioCodecType == platform::CodecType::None + ? -1 : static_cast(platform::audioCodecPins.mclk); inited_ = platform::audioMicInit(mic_, static_cast(wsPin), static_cast(sdPin), - static_cast(sckPin), sampleRate()); + static_cast(sckPin), mclkPin, sampleRate()); if (!inited_) { setStatus(kInitFailMsg, Severity::Error); return; } + // Now configure the codec over I2C (MCLK is running). A no-op returning true + // on direct-mic boards, so the call is uniform. The codec then streams its ADC + // onto the I2S bus the read above drains. + if (!platform::audioCodecInit(platform::audioCodecType, platform::audioCodecPins, + sampleRate())) { + deinit(); // tear the I2S channel back down — we couldn't bring the codec up + setStatus("mic: codec init failed — check I2C wiring", Severity::Error); + return; + } dc_.reset(); // start the high-pass clean for the new stream // The INMP441 emits ~250 ms of power-on settling garbage after the clock // starts. The read is non-blocking (hot-path rule), so we can't drain a @@ -242,6 +258,7 @@ class AudioModule : public MoonModule { void deinit() { if constexpr (!platform::hasI2sMic) return; if (inited_) platform::audioMicDeinit(mic_); + platform::audioCodecDeinit(); // releases the codec + its I2C bus (no-op if none) inited_ = false; filled_ = 0; // Publish silence: latestFrame() hands frame_ to consumers whenever this is diff --git a/src/core/Control.h b/src/core/Control.h index 921037c8..a9524e15 100644 --- a/src/core/Control.h +++ b/src/core/Control.h @@ -5,6 +5,15 @@ #include #include +// The highest valid GPIO number, the default clamp for addPin's Pin controls. +// The build injects the real per-chip value (-DMM_MAX_GPIO=CONFIG_SOC_GPIO_PIN_COUNT-1 +// from the IDF; see esp32/main/CMakeLists.txt). This fallback keeps Control.h core +// and standalone-compilable (no platform include, no build flag required) — it's +// the widest current ESP32-family ceiling, so it never *under*-clamps a real board. +#ifndef MM_MAX_GPIO +#define MM_MAX_GPIO 63 +#endif + namespace mm { // Dotted-quad parser used by ControlType::IPv4 writes (HttpServerModule @@ -229,9 +238,14 @@ class ControlList { // never exceeds ~54 on any ESP32-family chip, so int8 (−128..127) is ample and // smaller than int16. Renders as a plain number input, not a slider (see // ControlType::Pin): a GPIO has no meaningful range to drag. min/max are the - // valid-GPIO span (−1..52), used only as a server-side write-clamp guard; the - // UI keys rendering off the "pin" type string, not the range. - void addPin(const char* name, int8_t& var, int16_t min = -1, int16_t max = 52) { + // valid-GPIO span, used only as a server-side write-clamp guard; the UI keys + // rendering off the "pin" type string, not the range. The default max is the + // chip's real GPIO ceiling: MM_MAX_GPIO, which the build defines per-target from + // the IDF's CONFIG_SOC_GPIO_PIN_COUNT (61 on the S31, whose audio pins reach + // GPIO57) — derived, not hand-maintained. The fallback below keeps Control.h + // core/standalone (it compiles with no build flag); the real per-chip value is + // injected by CMake. Callers don't repeat it. + void addPin(const char* name, int8_t& var, int16_t min = -1, int16_t max = MM_MAX_GPIO) { grow(); controls_[count_++] = {&var, name, 0, ControlType::Pin, min, max}; } diff --git a/src/core/DeviceIdentify.h b/src/core/DeviceIdentify.h index 67f05f51..3eacf3d4 100644 --- a/src/core/DeviceIdentify.h +++ b/src/core/DeviceIdentify.h @@ -1,21 +1,17 @@ #pragma once -#include #include -#include -// Pure device-identification logic for DevicesModule, factored out so it is -// host-unit-testable without network I/O (the "second caller extracts the helper" -// pattern, cf. PinList.h / Control.cpp's parsers — the unit test is the second -// caller). Everything here is a pure function over a response-body string: no -// sockets, no module state. Tolerant of any input (a hostile / truncated / garbage -// body yields DevType::Generic and an empty name), per the robustness contract for -// network-sourced data — this reads a FOREIGN device's reply, so it stays local and -// defensive rather than using the project's own JsonUtil control-key parser. +// The device-kind enum + its wire labels, shared by DevicesModule, the interop +// plugins (core/DevicePlugin.h), persistence, and the UI list. The matching plugin +// classifies a device straight from its UDP presence packet (the 44-byte WledPacket +// header, with a projectMM marker distinguishing a peer from a plain WLED), so the +// classification logic lives with each plugin; this header carries only the small +// shared vocabulary so the enum doesn't pull in the module. namespace mm { -// What a discovered device is, inferred from its HTTP response. +// What a discovered device is. enum class DevType : uint8_t { Generic = 0, ProjectMM = 1, Wled = 2 }; inline const char* devTypeStr(DevType t) { @@ -27,59 +23,4 @@ inline const char* devTypeStr(DevType t) { return "generic"; } -// Classify a live host from a probe body. `stateBody` is the /api/state reply (or -// null/empty if that probe didn't 200); `infoBody` is the /json/info reply. A -// projectMM /api/state carries a top-level "modules" array; a WLED /json/info -// carries "WLED"; anything else live is generic. -inline DevType classifyDevice(const char* stateBody, const char* infoBody) { - if (stateBody && std::strstr(stateBody, "\"modules\"")) return DevType::ProjectMM; - if (infoBody && std::strstr(infoBody, "WLED")) return DevType::Wled; - return DevType::Generic; -} - -// Minimal one-shot JSON string scan: find `anchor`; if `valueKey` is null, read the -// first "..." after the next colon; if non-null, read the first "..." after the next -// occurrence of `valueKey` (for a value nested in an object, e.g. the "deviceName" -// control's "value"). Writes "" when not found. Bounds-checked; tolerant of garbage. -inline void extractStringAfter(const char* body, const char* anchor, - const char* valueKey, char* out, size_t outLen) { - if (outLen == 0) return; - out[0] = 0; - if (!body || !anchor) return; - const char* p = std::strstr(body, anchor); - if (!p) return; - p += std::strlen(anchor); - if (valueKey) { - p = std::strstr(p, valueKey); - if (!p) return; - p += std::strlen(valueKey); - } - const char* colon = std::strchr(p, ':'); - if (!colon) return; - const char* q = std::strchr(colon, '"'); - if (!q) return; - q++; - size_t i = 0; - for (; q[i] && q[i] != '"' && i + 1 < outLen; i++) out[i] = q[i]; - out[i] = 0; -} - -// Pull a display name from the probe body for a given type. projectMM exposes -// deviceName as a CONTROL OBJECT in /api/state — -// {"name":"deviceName","type":"text","value":"Bench P4"} — so anchor on -// "deviceName" then read the next "value" (NOT the first quoted token, which is the -// "text" type field). WLED's /json/info has a top-level bare "name" string. Leaves -// `out` empty when no name is found (caller falls back to the IP). -inline void extractDeviceName(DevType type, const char* body, - char* out, size_t outLen) { - if (outLen == 0) return; - out[0] = 0; - if (!body) return; - if (type == DevType::ProjectMM) { - extractStringAfter(body, "\"deviceName\"", "\"value\"", out, outLen); - } else if (type == DevType::Wled) { - extractStringAfter(body, "\"name\"", nullptr, out, outLen); - } -} - } // namespace mm diff --git a/src/core/DevicePlugin.h b/src/core/DevicePlugin.h new file mode 100644 index 00000000..fec7b389 --- /dev/null +++ b/src/core/DevicePlugin.h @@ -0,0 +1,112 @@ +#pragma once + +#include "core/DeviceIdentify.h" // DevType +#include "core/WledPacket.h" // the 65506 presence packet projectMM + WLED both use + +#include +#include + +// Device-interop plugin seam — how a foreign lighting/IoT system "hooks in" to +// projectMM's device discovery. DevicesModule owns the device model + the UDP discovery +// listener and stays domain-neutral; each *plugin* teaches it to recognise one ecosystem +// (our own projectMM, WLED, later ESPHome / Tasmota / Hue) from a UDP presence broadcast. +// This is the adapter pattern (cf. the ListSource data-source seam, ModuleFactory's +// register-by-name): the core is generic, the per-system knowledge lives with its plugin, +// so a new system is *one new plugin file*, never a core edit. +// +// Discovery is PASSIVE UDP: a plugin declares the broadcast port it listens on and +// classifies a received datagram into a device. This replaces the former mDNS *query* +// path, which destabilised our own mDNS advertise (a PTR query for a service we also host +// exhausts the IDF mDNS pool — see docs/history/decisions.md). mDNS is +// now advertise-only (so the WLED app + Home Assistant find us); discovery never queries. +// +// The seam covers the discovery half, with two concrete plugins (projectMM and WLED) that +// prove it isn't shaped to one system. It is sized to also carry a control half (a per-plugin +// `command()` that translates "set brightness" into a system's protocol) without reshaping — +// that's why `DiscoveredDevice` stays plain and the iteration is generic. + +namespace mm { + +// A device a plugin recognised from a presence packet. Plain data; the IP comes from the +// datagram source, the plugin fills the kind + name. (A future hub plugin adds a resource +// list here; the command half adds capability/auth.) +struct DiscoveredDevice { + DevType type = DevType::Generic; + char name[24] = {}; +}; + +// Copy a discovered name into a DiscoveredDevice, truncating to the display-name field. +// `%.*s` bounds the read so truncation is explicit and the format-truncation check passes. +inline void setDeviceName(DiscoveredDevice& d, const char* src) { + std::snprintf(d.name, sizeof(d.name), "%.*s", + static_cast(sizeof(d.name) - 1), src ? src : ""); +} + +// One interop plugin. Stateless const singleton — no per-device state (that lives in the +// module's list). A plugin declares the UDP port it listens on and turns a received +// presence datagram into a device. +class DevicePlugin { +public: + virtual ~DevicePlugin() = default; + + // A short label for logs / the UI ("projectMM", "WLED"). Flash-literal lifetime. + virtual const char* label() const = 0; + + // The UDP port this plugin's ecosystem broadcasts presence on. The bundled plugins + // share one port (projectMM + WLED both use 65506), and DevicesModule enforces that + // invariant: it binds a single listener to the plugins' common discoveryPort() and + // offers every datagram to all of them. + virtual uint16_t discoveryPort() const = 0; + + // Classify a received datagram (`data`/`len`) from `srcIp`. Returns true and fills + // `out` when this plugin owns the packet; false to decline (let another plugin handle + // it). Defensive per the robustness contract: a short/garbage datagram → decline, + // never read out of bounds, never crash. + virtual bool classifyPacket(const uint8_t* data, size_t len, const uint8_t srcIp[4], + DiscoveredDevice& out) const = 0; + + // (reserved) Translate a generic command (set brightness, …) into this system's + // protocol and send it. Added when a control consumer exists; not built now. + // virtual bool command(const DiscoveredDevice& dev, const DeviceCommand& cmd) const; +}; + +// --- projectMM plugin: a peer's WLED-valid presence packet carrying our `MM` marker. --- +// projectMM broadcasts a WLED-VALID 65506 packet (so WLED apps list us too) stamped with a +// sentinel in the version field. This plugin claims a packet ONLY if that marker is present +// — so a peer projectMM device is typed projectMM, and the WledPlugin (below) doesn't also +// claim it as a generic WLED. Listed first for that priority. +class MmPlugin : public DevicePlugin { +public: + const char* label() const override { return "projectMM"; } + uint16_t discoveryPort() const override { return WledPacket::kPort; } + + bool classifyPacket(const uint8_t* data, size_t len, const uint8_t /*srcIp*/[4], + DiscoveredDevice& out) const override { + if (!WledPacket::isValid(data, len) || !WledPacket::hasMmMarker(data, len)) return false; + out.type = DevType::ProjectMM; + char name[24]; + WledPacket::readName(data, name, sizeof(name)); + setDeviceName(out, name); + return true; + } +}; + +// --- WLED plugin: any valid WLED presence packet WITHOUT our marker. --------------- +class WledPlugin : public DevicePlugin { +public: + const char* label() const override { return "WLED"; } + uint16_t discoveryPort() const override { return WledPacket::kPort; } + + bool classifyPacket(const uint8_t* data, size_t len, const uint8_t /*srcIp*/[4], + DiscoveredDevice& out) const override { + if (!WledPacket::isValid(data, len)) return false; + if (WledPacket::hasMmMarker(data, len)) return false; // that's a projectMM peer + out.type = DevType::Wled; + char name[24]; + WledPacket::readName(data, name, sizeof(name)); + setDeviceName(out, name); + return true; + } +}; + +} // namespace mm diff --git a/src/core/DevicesModule.h b/src/core/DevicesModule.h index 3f1774a9..fdb87c5b 100644 --- a/src/core/DevicesModule.h +++ b/src/core/DevicesModule.h @@ -5,8 +5,9 @@ #include "core/JsonSink.h" #include "core/JsonUtil.h" // recursive reader — restoreList parses the persisted array #include "core/Sort.h" // mm::insertionSort — generic bounded sort (core); we supply the comparator -#include "core/DeviceIdentify.h" // DevType, classifyDevice, extractDeviceName (pure, unit-tested) -#include "core/FilesystemModule.h" // FilesystemModule::noteDirty — persist on sweep / age-out change +#include "core/DeviceIdentify.h" // DevType, devTypeStr (the device-kind enum + its labels) +#include "core/DevicePlugin.h" // the interop plugin seam + the bundled plugins +#include "core/FilesystemModule.h" // FilesystemModule::noteDirty — persist on list change #include "platform/platform.h" #include @@ -15,20 +16,28 @@ namespace mm { -// Discovers other devices on the LAN, identifies what each is, and presents them -// as a browsable list. Core + domain-neutral: it finds "a projectMM / a WLED / a -// generic HTTP device", and light modules (Art-Net sync, future SuperSync) consume -// the list rather than living here. Submodule of NetworkModule — discovery depends -// on the network being up. See docs/moonmodules/core/DevicesModule.md. +// Discovers other devices on the LAN by UDP presence broadcast and presents them as a +// browsable list. Core + domain-neutral: it finds "a projectMM / a WLED device" and light +// modules (Art-Net sync, future SuperSync, device groups) consume the list rather than +// living here. Submodule of NetworkModule — discovery depends on the network being up. +// See docs/moonmodules/core/DevicesModule.md. // -// v1: a throttled subnet sweep (a few IPs per loop1s tick, never blocking the -// render loop) HTTP-probes each host and classifies the response; results render -// in the generic List control (this module is its ListSource). Read-only. +// Discovery is PASSIVE UDP: each device BROADCASTS a small presence packet on a well-known +// port (WLED + projectMM both use UDP 65506 with the 44-byte WLED-compatible header — see +// WledPacket), and this module LISTENS (a bound UdpSocket per port its DevicePlugins claim, +// drained non-blocking each tick). A plugin classifies each datagram into a `Device`. +// projectMM also broadcasts its OWN presence on a slow cadence so peers discover it (and a +// WLED app browsing 65506 can list it too). This replaces the former mDNS *query* path, +// which destabilised our own mDNS advertise (a PTR query for a service we also host +// exhausts the IDF mDNS pool — see docs/history/decisions.md). mDNS is now +// advertise-ONLY (so the WLED native app + Home Assistant discover us); discovery never +// queries. Fast (no subnet sweep), hot-path-safe (non-blocking recv), extensible (a new +// ecosystem is one new plugin file). Reliable device-to-device *commands* ride REST; +// latency-critical sync rides its own UDP — never this listener (lossy-OK presence only). class DevicesModule : public MoonModule, public ListSource { public: - // Wire this device's own name (deviceName) before setup so the self row in the - // list matches the status page / router / mDNS. Borrowed pointer — caller owns - // stable storage (e.g. SystemModule::deviceName()). + // Wire this device's own name (deviceName) before setup so the self row matches the + // status page / router / mDNS. Borrowed pointer — caller owns stable storage (SystemModule). void setSelfName(const char* name) { selfName_ = name; } // ListSource — rows are produced straight from devices_ (no copy, no alloc). @@ -55,21 +64,16 @@ class DevicesModule : public MoonModule, public ListSource { sink.writeJsonString(d.name[0] ? d.name : ip); sink.appendf(",\"ip\":\"%s\",\"url\":\"http://%s/\",\"type\":\"%s\"", ip, ip, devTypeStr(d.type)); - writeSpeaks(sink, d.speaks); - writeVia(sink, d.via); // how it was found (mdns / scan / udp) — for the UI badge if (d.self) { sink.append(",\"self\":true"); // self is always "now" — no meaningful age } else if (d.cached) { - // Restored from persistence, not re-confirmed live this session — `ageSec` + // Restored from persistence, not re-heard live this session — `ageSec` // would be a fake "now" (the boot stamp), so emit `cached` instead. The UI - // shows "last seen: cached"; once a strategy re-sees it, cached clears and a - // real ageSec appears. + // shows "last seen: cached"; once a presence packet re-arrives, cached clears. sink.append(",\"cached\":true"); } else { - // Seconds since this device was last seen by any strategy. Computed here - // (device-side) so the UI gets one finished number, not a raw boot-relative - // clock it would have to reconcile; the same `now - lastSeenMs` the age-out - // uses, in seconds. Snapshot at state-push time. Wrap-safe (unsigned). + // Seconds since the last presence sighting. Computed device-side so the UI gets + // one finished number; the same `now - lastSeenMs` age-out uses. Wrap-safe. uint32_t ageSec = (platform::millis() - d.lastSeenMs) / 1000u; sink.appendf(",\"ageSec\":%u", static_cast(ageSec)); } @@ -78,16 +82,12 @@ class DevicesModule : public MoonModule, public ListSource { // ListSource restore (persistence load): parse the saved `devices` array with the // recursive mm::json reader and rebuild devices_, so the last-known list shows on - // boot before any scan. Tolerant of a malformed/over-large file (parse fails → - // false → empty list). Self is dropped (re-added live via upsertSelf with the - // current IP); missed=0 so a device that's truly gone ages out after the first - // live sweep. + // boot before any announcement arrives. Tolerant of a malformed/over-large file + // (parse fails → false → empty list). Self is dropped (re-added live via upsertSelf + // with the current IP). Tolerates an OLD persisted file with extra keys (e.g. the + // former `via`) — the keyed reader ignores them (robust to any input). bool restoreList(const char* json, const char* key) override { deviceCount_ = 0; - // Core does the parse / array-navigate / iterate / malformed-safety - // (forEachListElement); this body is just "fill one device from this object". - // Capture its result — we still need to sort before returning, so we can't - // `return` it inline (that skipped sortByName, leaving the cache unsorted). const bool ok = mm::json::forEachListElement(json, key, [&](const mm::json::JsonDoc& doc, const mm::json::JsonNode* el) { if (deviceCount_ >= kMaxDevices) return; @@ -105,466 +105,288 @@ class DevicesModule : public MoonModule, public ListSource { : (std::strcmp(typeStr, "WLED") == 0) ? DevType::Wled : DevType::Generic; d.self = false; - d.speaks = ProtoHttp; - d.via = 0; // no live sighting yet — via fills in when a strategy re-sees it - d.cached = true; // restored, not re-confirmed live → UI shows "cached", not a time - // Stamp "now" so the cached entry gets a full kStaleMs grace window to - // re-announce before age-out drops it (a still-alive but slow device). + d.cached = true; // restored, not re-heard live → UI shows "cached", not a time + // Stamp "now" so the cached entry gets its kCachedGraceMs PROBATION window + // (not the full 24 h) to be re-confirmed by a live packet before age-out + // drops it. The persisted file has no real last-seen time — faking it as the + // full 24 h would let a long-gone device survive forever across reboots (the + // clock resets every boot). A live packet promotes it (clears `cached`, real + // 24 h window); silence within probation means it's a ghost — drop it. d.lastSeenMs = platform::millis(); }); - sortByName(); // cached list shows alphabetically too, before the first sweep + sortByName(); // cached list shows alphabetically too, before the first sighting return ok; // false on a malformed/missing file (list left empty) } void onBuildControls() override { MoonModule::onBuildControls(); - // `scan` is a momentary ACTION (rescan now), not an on/off state — a Button, - // not a Bool toggle (a toggle next to the "scanning…" status reads as two - // unrelated states). onUpdate runs the rescan. - controls_.addButton("scan"); - // No "status" control — sweep state goes through MoonModule::setStatus(), the - // standard status channel the UI renders generically (mod.status/severity). - // Sweep progress (host 0..254, plain count not KB). Always present: the WS - // state push patches values but not structure, so a show-only-while-scanning - // hide flag wouldn't update live. At rest the value is 0 (empty bar); pressing - // `scan` mid-sweep just restarts the sweep (harmless), so no need to gate it. - controls_.addProgress("progress", scanProgress_, 254, /*bytes=*/false); controls_.addList("devices", *this); // this module is the ListSource } - void onUpdate(const char* controlName) override { - if (std::strcmp(controlName, "scan") == 0) restartScan(); - } - void setup() override { MoonModule::setup(); // The last-known device list is restored automatically before setup() by the - // persistence overlay (the `devices` List control is persistable and - // round-trips as JSON — restoreList rebuilt devices_). So the UI shows it - // INSTANTLY on boot — no waiting for a fresh sweep (the win for slow-to- - // discover devices like a PC instance or generic host that mDNS can't find). + // persistence overlay (the `devices` List control round-trips as JSON). So the + // UI shows it INSTANTLY on boot — no waiting for an announcement to re-arrive. if (deviceCount_) { std::snprintf(statusBuf_, sizeof(statusBuf_), "%u device%s (cached)", deviceCount_, deviceCount_ == 1 ? "" : "s"); } - setStatus(statusBuf_); // "idle", or the cached-count summary - // Don't scan here — the network isn't up yet (DHCP lands a few seconds after - // boot). loop1s() kicks the ONE boot sweep once a local IP appears. + setStatus(statusBuf_); } - // The sweep advances one IP per tick. It runs on the render task and each probe - // BLOCKS up to kProbeTimeoutMs on a dead host — a hot-path stall that would - // flicker the LEDs. So the sweep runs ONCE at boot (LEDs not yet critical) and - // otherwise only on an explicit `scan` press; there is NO periodic background - // scan. Moving the blocking probe to its own FreeRTOS task is the enabler for - // safe periodic scanning (and a UDP presence beacon) — see backlog. + // Every tick: ensure we're online, drain inbound presence packets through the plugins, + // broadcast our own presence on a slow cadence, and age out devices unheard for + // kStaleMs. The drain is non-blocking (recvFrom returns -1 when nothing pending), so it + // never stalls the tick — the hot-path-safe replacement for the old mDNS query. void loop1s() override { MoonModule::loop1s(); - if (hostCursor_ >= 0) { - stepScan(); - } else if (!sweptOnce_) { - // One-time boot sweep, started as soon as the network is up. - uint8_t local[4] = {}; - localIp(local); - if (local[0] || local[1] || local[2] || local[3]) restartScan(); + uint8_t local[4] = {}; + localIp(local); + const bool online = local[0] || local[1] || local[2] || local[3]; + if (!online) return; // no network yet — nothing to discover + + // Re-register the self row every tick against the CURRENT local IP (idempotent — + // find-or-update). Doing it once would pin the first-seen address forever; a later + // DHCP renew / WiFi↔Eth switch changes our IP, and upsertSelf must follow it (and + // ageOut drops the row left at the old address). Cheap: a bounded findByIp + stamp. + upsertSelf(local); + ensureListener(); + + // Drain every presence packet received since the last tick (bounded — a busy LAN + // sends a handful per interval), classifying each through the plugins. + uint8_t buf[64]; + uint8_t srcIp[4]; + for (int i = 0; i < kMaxDrainPerTick; i++) { + int n = listener_.recvFrom(buf, sizeof(buf), srcIp); + if (n <= 0) break; // -1 = nothing pending; done for this tick + mergePacket(buf, static_cast(n), srcIp); } - // mDNS browse runs EVERY tick, independent of the HTTP sweep: it's async and - // non-blocking (a cheap poll, no per-host timeout), so it's safe on loop1s - // where the blocking HTTP probe is not. It catches devices that advertise a - // service (WLED, projectMM, generic `_http._tcp`) as they come and go, without - // a subnet sweep — the standard, push-style discovery the architecture calls for. - stepMdns(); - // Age out here, not at sweep-end: discovery now arrives on several cadences - // (a minutes-long HTTP sweep, a seconds-long mDNS lap, a future async UDP - // beacon), so freshness is a per-device timestamp and the drop is a simple - // "unseen too long" check every tick — independent of any one strategy's cycle. - ageOut(); + + // Broadcast our own presence every kBroadcastEverySec ticks so peers discover us. + if (++broadcastTick_ >= kBroadcastEverySec) { + broadcastTick_ = 0; + broadcastPresence(local); + } + + ageOut(local); } ModuleRole role() const override { return ModuleRole::Generic; } -private: - // DevType / classifyDevice / extractDeviceName live in DeviceIdentify.h (pure + - // unit-tested). devTypeStr() there replaces the former local typeStr(). - - // Protocols a device is known to speak, as a bitmask. v1 discovery only proves - // HTTP (the scan probes it), so that's all that gets set today — but the field - // exists so additional discovery strategies (mDNS browse, UDP/ArtPoll/DDP/OSC, - // RTP-MIDI; see DevicesModule.md "Discovery is per-protocol") fill in more bits - // without reshaping the device record or the wire format. A consumer (Art-Net - // sync, fleet OTA) reads `speaks` to know how it can talk to a device. - enum Proto : uint8_t { - ProtoHttp = 1 << 0, // an HTTP API (REST) — the only one v1 discovers - ProtoArtnet = 1 << 1, // Art-Net / sACN (future: ArtPoll discovery) - ProtoDdp = 1 << 2, // DDP (future) - // … mDNS-advertised services, OSC, RTP-MIDI, etc. as strategies are added. - }; - - // How a device was discovered, as a bitmask — a device can be found by more than - // one strategy at once (mDNS browse AND the HTTP sweep both see a projectMM peer), - // so this is OR-ed like `speaks`, not a single last-writer-wins value. The detail - // panel renders it so "what did mDNS find vs the scan" is visible. UDP (a future - // presence beacon) is the next bit; it arrives on its own async cadence, which is - // exactly why discovery freshness is a per-device timestamp, not a per-sweep - // counter (no single sweep boundary to hang a counter off — see lastSeenMs). - enum Via : uint8_t { - ViaScan = 1 << 0, // answered the HTTP subnet sweep - ViaMdns = 1 << 1, // advertised a browsed mDNS service - ViaUdp = 1 << 2, // announced via a UDP presence beacon (future) - }; + // Test seam: feed a synthetic presence datagram through the real classify→upsert + // pipeline, exactly as the live recvFrom loop does. The desktop unit/scenario tests + // drive the full discovery path (plugin claim, type priority, name/IP merge) with + // hand-built packets — no network needed. Not used in production. + void injectPacketForTest(const uint8_t* data, size_t len, const uint8_t srcIp[4]) { + mergePacket(data, len, srcIp); + } +private: struct Device { uint8_t ip[4] = {}; char name[24] = {}; DevType type = DevType::Generic; - uint8_t speaks = 0; // Proto bitmask — protocols this device is known to speak - uint8_t via = 0; // Via bitmask — which strategies have discovered it bool self = false; - bool cached = false; // restored from persistence, not yet re-seen LIVE this - // session (via is still empty, lastSeenMs is the boot - // stamp, not a real sighting). Cleared on the first live - // sighting; until then the UI shows "cached", not a time. - uint32_t lastSeenMs = 0; // platform::millis() at the most recent sighting (any - // strategy). Age-out drops a non-self device unseen for - // kStaleMs — strategy-agnostic, so HTTP/mDNS/UDP, each on - // its own cadence, all just stamp "now" when they see it. + bool cached = false; // restored from persistence, not yet re-heard live this + // session. Cleared on the first live sighting. + uint32_t lastSeenMs = 0; // platform::millis() at the most recent mDNS sighting. + // Age-out drops a non-self device unheard for kStaleMs. }; - // Append a `speaks` JSON array (e.g. ["http"]) for a device's protocol bitmask. - static void writeSpeaks(JsonSink& sink, uint8_t speaks) { - sink.append(",\"speaks\":["); - bool first = true; - auto emit = [&](uint8_t bit, const char* tag) { - if (!(speaks & bit)) return; - if (!first) sink.append(","); - sink.appendf("\"%s\"", tag); - first = false; - }; - emit(ProtoHttp, "http"); - emit(ProtoArtnet, "artnet"); - emit(ProtoDdp, "ddp"); - sink.append("]"); - } - - // Append a `via` JSON array (e.g. ["scan","mdns"]) for a device's discovery bitmask - // — how the device was found, so the UI can show mDNS-found vs scan-found at a glance. - static void writeVia(JsonSink& sink, uint8_t via) { - sink.append(",\"via\":["); - bool first = true; - auto emit = [&](uint8_t bit, const char* tag) { - if (!(via & bit)) return; - if (!first) sink.append(","); - sink.appendf("\"%s\"", tag); - first = false; - }; - emit(ViaScan, "scan"); - emit(ViaMdns, "mdns"); - emit(ViaUdp, "udp"); - sink.append("]"); - } + static constexpr uint8_t kMaxDevices = 32; // a LAN's worth; bounded, no heap + // Broadcast our presence every this-many loop1s ticks (≈ seconds). Slow + light, like + // WLED's ~30 s beacon; a new device appears within this window. A departed device + // clears within kStaleMs (sized to a few intervals so a present-but-quiet device isn't + // dropped between its broadcasts). + static constexpr uint32_t kBroadcastEverySec = 10; + // Keep a device listed for 24 h after its last sighting, then drop. The list is a + // durable "devices I've seen" history (persisted to flash, restored on boot), not just + // "live right now": a device that goes offline survives a reboot and lingers a full day, + // its freshness dot ageing green → yellow (>1 min) → red (>1 h) so the UI shows it + // fading before it finally purges. A still-present device re-broadcasts every ~10 s, so + // 24 h is never hit by a live peer. + static constexpr uint32_t kStaleMs = 24u * 60u * 60u * 1000u; + // Probation for a CACHED (restored-from-persistence, never-re-heard) device: keep it + // only this long for a live packet to re-confirm it, else drop it as a ghost. Short, so + // a stale persisted entry doesn't survive across reboots — the persisted file has no + // real last-seen time, so a cached device's clock is "boot", not "actually last seen". + static constexpr uint32_t kCachedGraceMs = 60u * 1000u; + static constexpr int kMaxDrainPerTick = 16; // cap packets processed per tick (bounded work) + + // The interop plugins. Order matters: MmPlugin is first, so a projectMM peer's + // marker-stamped packet is typed projectMM before WledPlugin would see it as a plain + // WLED. A new system (ESPHome, Tasmota, Hue) is added by writing one plugin and listing + // it here — no other change. const singletons, no per-device state. + MmPlugin mmPlugin_; + WledPlugin wledPlugin_; + static constexpr uint8_t kPluginCount = 2; + const DevicePlugin* plugins_[kPluginCount] = { &mmPlugin_, &wledPlugin_ }; + + platform::UdpSocket listener_; // bound to the presence port; drained each tick + bool listenerBound_ = false; + uint32_t broadcastTick_ = 0; // counts loop1s ticks toward the next presence broadcast - static constexpr uint8_t kMaxDevices = 32; // a LAN's worth; bounded, no heap - // One IP per tick: a probe blocks up to kProbeTimeoutMs on a dead host, and - // loop1s must not stall the render loop. 1 IP/tick → a /24 sweep takes ~254 s - // worst case (all-dead subnet), but each tick blocks at most one timeout. The - // probe short-circuits after the FIRST GET times out (a dead host answers no - // URL), so a sparse subnet costs ~1×timeout per empty IP, not 3×. - static constexpr uint8_t kProbesPerTick = 1; - // Short timeout: this GET blocks the scheduler thread (and thus one render tick) on a dead host, - // so it stays small to keep the boot sweep from stuttering animation during the ~4 min the /24 - // takes. A live host on a LAN answers in a few ms; 30 ms covers a slow responder while keeping - // the worst-case per-tick stall to ~30 ms. - static constexpr uint32_t kProbeTimeoutMs = 30; - // Drop a non-self device unseen by ANY strategy for this long. 24 h is deliberately - // generous: mDNS re-confirms its devices every few-second browse lap (cheap), but an - // HTTP-scan-only device (a PC instance, a generic host) has no cheap recurring - // refresh — the sweep is boot-once + manual, not periodic — so a short timeout would - // wrongly drop a still-alive device and force a re-scan. A day-long window lets such - // a device persist on its single sighting while a genuinely-departed device still - // clears itself within a day. Each sighting (HTTP/mDNS/UDP) restamps lastSeenMs. - static constexpr uint32_t kStaleMs = 24u * 60u * 60u * 1000u; // 24 hours Device devices_[kMaxDevices]; uint8_t deviceCount_ = 0; - bool sweptOnce_ = false; // the one boot sweep has completed - const char* selfName_ = nullptr; // this device's name (wired via setSelfName) - uint32_t scanProgress_ = 0; // current host index 1..254 (0 = idle), for the Progress bar + const char* selfName_ = nullptr; // this device's name (wired via setSelfName) char statusBuf_[40] = "idle"; - // Probe response buffer — a member, not a per-call stack local: /api/state's - // deviceName can sit past 512 B on a multi-module device, so this needs ~1 KB, - // too large for a stack frame in the scheduler task. One probe runs per tick, so - // a single reused buffer suffices (no concurrency). Part of the module's fixed - // footprint (~1 KB), allocated once. - char probeBuf_[1024]; - - // Sweep cursor: hostLow_ walks 1..254 across the local /24. -1 = no scan running - // (no network yet, or sweep finished). The subnet's first three octets come from - // the local IP, captured at restartScan(). - int16_t hostCursor_ = -1; - uint8_t subnet_[3] = {}; // first three octets of the /24 being swept - - // True when a control or first-run kicks off a fresh full sweep. Captures the - // local IP (and so the subnet); marks every known device unseen-this-sweep. - void restartScan() { - uint8_t local[4] = {}; - localIp(local); - if (local[0] == 0 && local[1] == 0 && local[2] == 0 && local[3] == 0) { - std::snprintf(statusBuf_, sizeof(statusBuf_), "no network"); - setStatus(statusBuf_, Severity::Warning); - hostCursor_ = -1; - scanProgress_ = 0; // back to idle — no stale bar left showing - return; - } - subnet_[0] = local[0]; subnet_[1] = local[1]; subnet_[2] = local[2]; - hostCursor_ = 1; // sweep .1 .. .254 - scanProgress_ = 1; - std::snprintf(statusBuf_, sizeof(statusBuf_), "scanning %u.%u.%u.0/24", - subnet_[0], subnet_[1], subnet_[2]); - setStatus(statusBuf_); - // Ensure self is in the list even before its own IP is probed. - upsertSelf(local); - } - - // Probe up to kProbesPerTick hosts this tick; advance the cursor. When the sweep - // completes, age out devices not seen and go idle until the next restartScan. - void stepScan() { - if (hostCursor_ < 0) return; - uint8_t local[4] = {}; - localIp(local); - for (uint8_t i = 0; i < kProbesPerTick && hostCursor_ <= 254; i++, hostCursor_++) { - uint8_t ip[4] = {subnet_[0], subnet_[1], subnet_[2], - static_cast(hostCursor_)}; - // Don't probe our own IP: upsertSelf already gave it the right identity - // (projectMM, deviceName), and an HTTP request to ourselves mid-tick can - // race the server / loopback and misclassify us as generic. Just keep it - // fresh so age-out doesn't drop it. - if (ipEq(ip, local)) { if (Device* d = findByIp(ip)) { d->lastSeenMs = platform::millis(); d->cached = false; } continue; } - probe(ip); - } - // Advance the progress bar to the current cursor (1..254) so it tracks the sweep. - if (hostCursor_ <= 254) scanProgress_ = static_cast(hostCursor_); - if (hostCursor_ > 254) { - // Sweep finished — reset the bar to 0 (idle), not left full at 254. - scanProgress_ = 0; - hostCursor_ = -1; - sweptOnce_ = true; - // Age-out is no longer tied to the sweep end (it runs every tick in loop1s, - // off the per-device timestamp); the sweep just reports its result + persists. - std::snprintf(statusBuf_, sizeof(statusBuf_), "%u device%s", - deviceCount_, deviceCount_ == 1 ? "" : "s"); - setStatus(statusBuf_); - // Persist the fresh set so the next boot shows it instantly. The `devices` - // List control is persistable — marking dirty arms the standard - // FilesystemModule debounce, which serializes the List as JSON. - markDirty(); - FilesystemModule::noteDirty(); - } - } - - - // mDNS service types browsed, in round-robin. `_http._tcp` catches projectMM (we - // advertise it via mdnsInit) and any generic web device; `_wled._tcp` is WLED's - // own service. The list is the discovery surface — add `_esphome._tcp`, - // `_home-assistant._tcp`, etc. here as classification for them lands (the hit's - // service type already maps to a DevType in mdnsTypeFor). No state reshuffle. - struct MdnsService { const char* service; const char* proto; DevType type; }; - static constexpr MdnsService kMdnsServices[] = { - { "_http", "_tcp", DevType::Generic }, // projectMM + generic web devices - { "_wled", "_tcp", DevType::Wled }, - }; - static constexpr uint8_t kMdnsServiceCount = - sizeof(kMdnsServices) / sizeof(kMdnsServices[0]); - // mdnsBrowse is SYNCHRONOUS and blocks up to the timeout (the IDF PTR query waits the window for - // responders, returning when it elapses or the result cap fills), on the loop1s tick thread, so - // the time is charged to the tick. The timeout stays short AND the browse runs only every - // kMdnsEveryTicks-th tick: one ~20 ms hiccup every ~15 s stays invisible for a discovery feature - // (peers come and go on a slower scale than that), and FPS is untouched in between. A peer that - // answers after the window is caught on a later pass — discovery is continuous (each browse - // cycles to the next service type). The synchronous call holds no handle, so it stays correct - // under a concurrent UI refresh. - static constexpr uint32_t kMdnsBrowseMs = 20; // shorter blocking window → smaller render hiccup - static constexpr uint8_t kMdnsEveryTicks = 15; // browse less often → the hiccup is rarer (~15 s) - - uint8_t mdnsIndex_ = 0; // which service in kMdnsServices is browsed - uint8_t mdnsTick_ = 0; // throttle counter for the browse cadence - - // Browse one service type on the throttled cadence: query it (blocking, bounded), merge - // hits via the static callback, advance to the next type. The cycle wraps kMdnsServices - // forever, so new advertisers are picked up on later passes. - void stepMdns() { - if (++mdnsTick_ < kMdnsEveryTicks) return; - mdnsTick_ = 0; - const MdnsService& s = kMdnsServices[mdnsIndex_]; - platform::mdnsBrowse(s.service, s.proto, kMdnsBrowseMs, &DevicesModule::onMdnsHost, this); - advanceMdns(); - } - - void advanceMdns() { mdnsIndex_ = (mdnsIndex_ + 1) % kMdnsServiceCount; } - - // platform::MdnsHostCb — a found host for the service type at mdnsIndex_. Trampoline - // to the instance; `user` is `this` (set in mdnsBrowsePoll above). - static void onMdnsHost(const platform::MdnsHost& host, void* user) { - static_cast(user)->mergeMdnsHost(host); - } - - void mergeMdnsHost(const platform::MdnsHost& host) { - if (host.ip[0] == 0 && host.ip[1] == 0 && host.ip[2] == 0 && host.ip[3] == 0) - return; // unresolved — nothing to key on - // The browsed service type maps to a DevType (Generic for `_http`, Wled for - // `_wled`). A host on the GENERIC `_http` service carrying our `mm=1` TXT marker - // is a projectMM device — promote it, so an mDNS-only sighting classifies + names - // it without waiting for the HTTP scan. The promotion is gated on the base type - // being Generic: a definite service type (e.g. `_wled`) already says what the - // host is, so the marker must not override it (defensive — a real WLED won't - // carry `mm=1`, but a future service mustn't be silently relabelled projectMM). - const DevType baseType = kMdnsServices[mdnsIndex_].type; - DevType type = (host.isProjectMM && baseType == DevType::Generic) - ? DevType::ProjectMM : baseType; - upsertMdns(host.ip, type, host.hostname); - } void localIp(uint8_t out[4]) const { platform::ethGetIPv4(out); if (!out[0] && !out[1] && !out[2] && !out[3]) platform::wifiStaGetIPv4(out); } - // HTTP-probe one IP and classify. Tries port 80 first (ESP32 devices, WLED, - // generic web UIs); if nothing answers there, tries port 8080 (a projectMM - // DESKTOP instance serves its API on 8080, not 80 — see main_desktop.cpp). - // A live :80 host stops after :80, so the extra :8080 attempt only costs a - // second timeout on otherwise-empty IPs, keeping the per-IP budget bounded. - void probe(const uint8_t ip[4]) { - if (probePort(ip, 80)) return; - probePort(ip, 8080); - } - - // Probe one ip:port. Returns true if a host answered (so the caller can stop). - bool probePort(const uint8_t ip[4], uint16_t port) { - char url[48], ipStr[16]; - formatDottedQuad(ipStr, ip); - - // First GET doubles as the liveness check: status 0 == no host answered - // (timeout / connection refused). The response goes in probeBuf_, a member - // (NOT a stack local): /api/state's deviceName can sit past 512 B on a - // multi-module device, so the buffer must be ~1 KB — too large for this - // call's stack frame in the scheduler task, so it lives in the module's - // fixed footprint and is reused each probe (one probe per tick). - std::snprintf(url, sizeof(url), "http://%s:%u/api/state", ipStr, port); - int status = platform::httpGet(url, kProbeTimeoutMs, probeBuf_, sizeof(probeBuf_)); - if (status == 0) return false; // nothing on this port - // Only a 200 body is real /api/state — a 404/500 error page that happens to - // contain "modules" must not be misread as a projectMM (the WLED branch below - // already gates on 200). A non-200 still means the host is ALIVE, so fall - // through to the WLED probe / generic classification. - if (status == 200) { - DevType t = classifyDevice(probeBuf_, nullptr); - if (t == DevType::ProjectMM) { upsert(ip, t, probeBuf_); return true; } + // Offer a received presence datagram to each plugin; the first to classify it wins. + // (Order matters: MmPlugin is first, so a projectMM peer's marked packet is typed + // projectMM before WledPlugin would see it as a plain WLED.) + void mergePacket(const uint8_t* data, size_t len, const uint8_t srcIp[4]) { + if (!srcIp[0] && !srcIp[1] && !srcIp[2] && !srcIp[3]) return; // no source + for (const DevicePlugin* p : plugins_) { + DiscoveredDevice found; + if (p->classifyPacket(data, len, srcIp, found)) { upsertDevice(srcIp, found); return; } } - DevType t = DevType::Generic; + // No plugin claimed it — an unrecognised packet on a port we listen on; ignore. + } - // Not a projectMM — try the WLED info endpoint on this port. - std::snprintf(url, sizeof(url), "http://%s:%u/json/info", ipStr, port); - if (platform::httpGet(url, kProbeTimeoutMs, probeBuf_, sizeof(probeBuf_)) == 200) { - t = classifyDevice(nullptr, probeBuf_); - if (t == DevType::Wled) { upsert(ip, t, probeBuf_); return true; } + // Bind the discovery listener once the network is up. Idempotent — a no-op once bound. + // open() first (creates the fd AND enables SO_BROADCAST, which the presence broadcast + // needs); then bind() to the plugins' discovery port. The port comes from the plugins' + // discoveryPort() — the seam owns it, not a hardcoded constant — so adding a plugin on + // the same port is free. Today both plugins share one port (projectMM + WLED on 65506), + // so one socket receives + broadcasts; the assert pins that invariant. A future plugin + // on a DIFFERENT port is the trigger to grow this to one socket per distinct port (the + // shape is already a loop over plugins everywhere else). + void ensureListener() { + if (listenerBound_) return; + const uint16_t port = plugins_[0]->discoveryPort(); + for (const DevicePlugin* p : plugins_) + if (p->discoveryPort() != port) return; // divergent ports unsupported yet — see note + if (!listener_.open()) return; + if (listener_.bind(port)) { + listenerBound_ = true; + } else { + // bind failed (port busy this tick) — CLOSE the just-opened socket before + // returning, or each retry would open() a fresh fd and leak one per loop1s + // until the process runs out, slowing everything to a crawl. + listener_.close(); } - // Live host, not projectMM/WLED → generic HTTP device. - upsert(ip, DevType::Generic, nullptr); - return true; } - // Find-or-insert a device by IP; refresh its type/name and mark it seen. - void upsert(const uint8_t ip[4], DevType type, const char* body) { - uint8_t local[4] = {}; - localIp(local); - const bool isSelf = ipEq(ip, local); - Device* d = findByIp(ip); - if (!d) { - if (deviceCount_ >= kMaxDevices) return; // bounded; silently cap - d = &devices_[deviceCount_++]; - std::memcpy(d->ip, ip, 4); - } - d->type = type; - d->self = isSelf; - d->lastSeenMs = platform::millis(); - d->cached = false; // a live sighting — no longer just a cached entry - d->speaks |= ProtoHttp; // found via the HTTP scan → it speaks HTTP - d->via |= ViaScan; // discovered by the HTTP subnet sweep - extractDeviceName(type, body, d->name, sizeof(d->name)); - if (!d->name[0]) formatDottedQuad(d->name, ip); // fall back to the IP - sortByName(); // keep the list ordered AS devices arrive — not just at sweep end + // Broadcast our presence: a WLED-valid 44-byte packet (so WLED apps/devices browsing + // 65506 list us) stamped with the projectMM marker (so peer projectMM devices type us + // correctly). Discovery-only — carries no command, so a receiving WLED only lists us. + void broadcastPresence(const uint8_t ip[4]) { + uint8_t pkt[WledPacket::kSize]; + const char* n = (selfName_ && selfName_[0]) ? selfName_ : "projectMM"; + WledPacket::build(pkt, ip, n, boardTypeByte(), /*lightsOn=*/true); + WledPacket::stampMmMarker(pkt); + const uint8_t bcast[4] = {255, 255, 255, 255}; + listener_.sendToAddr(bcast, WledPacket::kPort, pkt, sizeof(pkt)); } - // True when `name` is just the device's own IP as a dotted quad — i.e. a placeholder - // a sighting fell back to because no real name was known yet. A later sighting with a - // genuine name should overwrite it (see upsertMdns); a real name never matches its IP. - static bool isIpPlaceholder(const char* name, const uint8_t ip[4]) { - char ipStr[16]; - formatDottedQuad(ipStr, ip); - return std::strcmp(name, ipStr) == 0; + // WLED's board-type byte (low 7 bits): 32=ESP32, 33=S2, 34=S3, 35=C3, 36=P4. Best-effort + // from the chip model string; an unknown chip falls back to 32 (plain ESP32) — purely + // informational in the packet (WLED shows an icon), never gates discovery. + static uint8_t boardTypeByte() { + const char* m = platform::chipModel(); + if (std::strstr(m, "S3")) return 34; + if (std::strstr(m, "S2")) return 33; + if (std::strstr(m, "C3")) return 35; + if (std::strstr(m, "P4")) return 36; + return 32; } - // Merge an mDNS browse hit. Like upsert() but the identity is weaker: mDNS proves - // the host advertises a service (so it speaks HTTP and is alive → missed=0), and - // for `_wled._tcp` the type is certain, but `_http._tcp` only says "some web - // device" — so a Generic hit must NOT downgrade a device the HTTP probe already - // identified as projectMM/WLED. We only raise the type (Generic → known), never - // lower it. The hostname becomes the display name only if we don't have a better - // one yet (the HTTP probe's deviceName wins when present). self is preserved. - void upsertMdns(const uint8_t ip[4], DevType type, const char* hostname) { + // Find-or-insert a device a plugin classified from a UDP presence packet; refresh + // type/name, mark seen. Our own presence packet (carrying the projectMM marker) resolves + // to our own source IP; mark that row self. Persistence is armed ONLY when a SAVED field + // (name/ip/type/self) actually changes — a mere re-sighting (lastSeenMs/cached) doesn't + // alter the serialized list, so it must not trigger a flash write every ~10 s broadcast. + void upsertDevice(const uint8_t ip[4], const DiscoveredDevice& found) { uint8_t local[4] = {}; localIp(local); const bool isSelf = ipEq(ip, local); Device* d = findByIp(ip); + bool persistChanged = false; if (!d) { - if (deviceCount_ >= kMaxDevices) return; + if (deviceCount_ >= kMaxDevices) return; // bounded; silently cap d = &devices_[deviceCount_++]; std::memcpy(d->ip, ip, 4); - d->type = type; // first sighting — take the mDNS type as-is - } else if (type != DevType::Generic) { - d->type = type; // a definite type (WLED) refines an existing row + d->type = found.type; // first sighting — take the plugin's type + persistChanged = true; // a new row changes the saved list + } + // A projectMM device broadcasts a marked, WLED-VALID packet — without the marker + // check a peer would relabel WLED. projectMM is the stronger identity (the marker is + // definitive): never downgrade an established projectMM device, only RAISE toward it. + const bool isMm = isSelf || found.type == DevType::ProjectMM; + const DevType newType = isMm ? DevType::ProjectMM + : (d->type != DevType::ProjectMM ? found.type : d->type); + if (d->type != newType) { d->type = newType; persistChanged = true; } + if (d->self != isSelf) { d->self = isSelf; persistChanged = true; } + d->lastSeenMs = platform::millis(); // transient — not persisted + d->cached = false; // transient — not persisted + // Update the display name when this packet is AUTHORITATIVE for the device's kind, + // so a peer RENAME propagates live (its next broadcast carries the new name). A + // projectMM-marked packet is authoritative for a projectMM row; a plain WLED packet + // for a WLED row — a WLED packet must NOT overwrite a projectMM device's name (a + // projectMM peer's packet without the marker is the lower-authority case). Always + // fill an empty/placeholder name regardless of authority. + const bool authoritative = + (found.type == DevType::ProjectMM && d->type == DevType::ProjectMM) || + (found.type == DevType::Wled); + if (found.name[0] && (!d->name[0] || isIpPlaceholder(d->name, ip) || authoritative) + && std::strcmp(d->name, found.name) != 0) { + std::snprintf(d->name, sizeof(d->name), "%s", found.name); + persistChanged = true; + } + if (!d->name[0]) { formatDottedQuad(d->name, ip); persistChanged = true; } + if (persistChanged) { // only a saved-field change touches disk + sort + sortByName(); + refreshStatus(); } - d->self |= isSelf; - d->lastSeenMs = platform::millis(); - d->cached = false; // a live sighting — no longer just a cached entry - d->speaks |= ProtoHttp; // advertised an HTTP service → speaks HTTP - d->via |= ViaMdns; // discovered by the mDNS browse - // Take the mDNS name when we don't have a real one yet. "No real name" means - // either empty OR a dotted-quad IP placeholder a prior sighting fell back to — - // a genuine advertised name (the peer's deviceName) should replace that IP. - // A name from the HTTP probe (a real deviceName) still wins: it's not an IP, so - // the isIpPlaceholder check leaves it alone. - if (hostname && hostname[0] && (!d->name[0] || isIpPlaceholder(d->name, ip))) - std::snprintf(d->name, sizeof(d->name), "%s", hostname); - if (!d->name[0]) formatDottedQuad(d->name, ip); - sortByName(); } - // Guarantee this device is listed (marked self) even before its IP is swept. + // Guarantee the self row exists at the current local IP (called every tick — idempotent). + // Self never ages out (the row at the current IP is restamped each tick). Re-sorts + + // refreshes status ONLY when the row actually changed (a fresh insert or an IP migration), + // not every tick — a no-op tick must not arm persistence (same rule as upsertDevice). void upsertSelf(const uint8_t ip[4]) { + bool changed = false; + // Demote any prior self row at a DIFFERENT address — our IP moved (DHCP / interface + // switch). It loses the self mark, so ageOut treats it as an ordinary peer and lets + // it expire, instead of staying immortal at the old address. + for (uint8_t i = 0; i < deviceCount_; i++) + if (devices_[i].self && !ipEq(devices_[i].ip, ip)) { devices_[i].self = false; changed = true; } + Device* d = findByIp(ip); if (!d) { if (deviceCount_ >= kMaxDevices) return; d = &devices_[deviceCount_++]; std::memcpy(d->ip, ip, 4); + changed = true; // a new row changes the saved list } - // Refresh identity on BOTH paths: if this IP was first seen by a sweep/mDNS as - // generic, learning it's us must promote it to projectMM, not leave the stale - // type. We are a projectMM and we speak HTTP. - d->type = DevType::ProjectMM; - d->speaks |= ProtoHttp; - d->self = true; - d->cached = false; // self is live by definition, not cached - d->lastSeenMs = platform::millis(); // self is always "now" → never ages out + if (d->type != DevType::ProjectMM) { d->type = DevType::ProjectMM; changed = true; } + if (!d->self) { d->self = true; changed = true; } + d->cached = false; + d->lastSeenMs = platform::millis(); // transient — not persisted if (!d->name[0]) { - // Show our own name (deviceName, wired via setSelfName) so the self row - // matches the status page / router / mDNS. "this device" is the last - // resort when no name was wired — same robustness contract as the rest. const char* n = (selfName_ && selfName_[0]) ? selfName_ : "this device"; std::snprintf(d->name, sizeof(d->name), "%s", n); + changed = true; + } + if (changed) { // only a real self-row change re-sorts + arms persistence + sortByName(); + refreshStatus(); } - sortByName(); // keep the list ordered (self slots in by name like any device) } + // True when `name` is just the device's own IP — a placeholder a sighting fell back + // to before a real name was known. A later sighting with a genuine name overwrites it. + static bool isIpPlaceholder(const char* name, const uint8_t ip[4]) { + char ipStr[16]; + formatDottedQuad(ipStr, ip); + return std::strcmp(name, ipStr) == 0; + } Device* findByIp(const uint8_t ip[4]) { for (uint8_t i = 0; i < deviceCount_; i++) @@ -576,37 +398,44 @@ class DevicesModule : public MoonModule, public ListSource { return std::memcmp(a, b, 4) == 0; } - // Drop non-self devices unseen by ANY strategy for longer than kStaleMs (a - // powered-off / departed device). Runs every tick off the per-device timestamp, - // so it's independent of any one strategy's cadence (HTTP sweep, mDNS lap, future - // UDP beacon). Stable compaction — preserves the by-name order upsert maintains. - // self never ages out (its timestamp is restamped to "now" on every sweep step). - // `now - lastSeenMs` in unsigned arithmetic is wrap-safe: the millis() counter - // wraps every ~49 days, but the elapsed interval (< kStaleMs) stays well below - // 2^31, so the subtraction yields the true elapsed time across a wrap. - void ageOut() { + // Drop non-self devices unheard for longer than kStaleMs. Self is restamped here so + // it never ages out while online. Stable compaction — preserves by-name order. + // `now - lastSeenMs` unsigned is wrap-safe (elapsed stays < 2^31 across the millis wrap). + void ageOut(const uint8_t local[4]) { const uint32_t now = platform::millis(); uint8_t w = 0; for (uint8_t r = 0; r < deviceCount_; r++) { Device& d = devices_[r]; - if (!d.self && (now - d.lastSeenMs) > kStaleMs) continue; // drop, stale + const bool isUs = ipEq(d.ip, local); + // The row at the CURRENT local IP is us — keep it fresh, never age it out. Guard + // on the ADDRESS, not the self flag: a stale self row at an old IP (after an IP + // change) is demoted by upsertSelf, so it falls through to the normal age-out. + if (isUs) d.lastSeenMs = now; + // A cached (restored, never re-heard live) device is on a SHORT probation — + // it's the fast-boot list, kept only long enough for a live packet to re-confirm + // it; otherwise it's a ghost. A live-confirmed device gets the full 24 h. + const uint32_t window = d.cached ? kCachedGraceMs : kStaleMs; + if (!isUs && (now - d.lastSeenMs) > window) continue; // drop, stale if (w != r) devices_[w] = d; w++; } if (w == deviceCount_) return; // nothing dropped — common case, no churn deviceCount_ = w; + refreshStatus(); + } + + void refreshStatus() { std::snprintf(statusBuf_, sizeof(statusBuf_), "%u device%s", deviceCount_, deviceCount_ == 1 ? "" : "s"); setStatus(statusBuf_); - // A drop changes the persisted set — save it so the cached list stays accurate. + // Persist the current set so the next boot shows it instantly. The `devices` + // List control is persistable — marking dirty arms the FilesystemModule debounce. markDirty(); FilesystemModule::noteDirty(); } // Order the list by device name (case-insensitive). Core's insertionSort does the - // work; we supply only the comparator — the domain stays a one-liner. Off the hot - // path (sweep-end / boot-load), bounded (<= kMaxDevices). The compare is inline - // (not strcasecmp/_stricmp, which differ across POSIX/Windows desktop). + // work; we supply only the comparator. Off the hot path, bounded (<= kMaxDevices). void sortByName() { mm::insertionSort(devices_, deviceCount_, [](const Device& a, const Device& b) { return ciLess(a.name, b.name); diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp index 3d95d9df..00a4f335 100644 --- a/src/core/HttpServerModule.cpp +++ b/src/core/HttpServerModule.cpp @@ -14,6 +14,7 @@ #include "core/Base64.h" #include "core/FilesystemModule.h" #include "core/FirmwareUpdateModule.h" +#include "core/SystemModule.h" // deviceName() for the WLED /json/info shim #include "platform/platform.h" #include "ui/ui_embedded.h" @@ -51,6 +52,10 @@ void HttpServerModule::loop20ms() { // (architecture.md § Parallelism). Drain BEFORE accept so a connection burst can't starve an // active send. No-op when nothing is in flight. drainPreviewSend(); + // Read any inbound WS frames: the native WLED app SETS state (its on/off + brightness + // slider) by SENDING a {on,bri} text frame over /ws, not by HTTP POST — so we must read + // the socket, not only push to it. Cheap (non-blocking, usually nothing pending). + pollWledStateFromWebSockets(); // Accept one HTTP connection per tick. auto conn = server_.accept(); if (conn.valid()) handleConnection(conn); @@ -64,17 +69,24 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { uint8_t buf[2048]; int totalRead = 0; - // Read request (blocking with timeout on desktop, retries on ESP32) - for (int attempt = 0; attempt < 20 && totalRead < static_cast(sizeof(buf) - 1); attempt++) { + // Read the request. read() is non-blocking (-1 = nothing pending yet), so the render + // loop is never stalled waiting for bytes (a blocking socket timeout used to freeze the + // whole loop). A just-accepted connection's request normally lands in the same read; if + // not, allow a SHORT bounded wait (≤ ~5 ms total) for it, then bail — an idle/half-open + // connection costs at most that, and the steady-state (nothing pending) costs ~0. + for (int empties = 0; totalRead < static_cast(sizeof(buf) - 1);) { int n = conn.read(buf + totalRead, sizeof(buf) - 1 - totalRead); if (n > 0) { totalRead += n; buf[totalRead] = 0; if (std::strstr(reinterpret_cast(buf), "\r\n\r\n")) break; + empties = 0; // got data — reset the patience counter } else if (n == 0) { - return; // peer closed - } else { - break; // timeout or error + return; // peer closed + } else { // -1 = nothing pending yet + if (totalRead > 0) break; // had a partial then nothing more — process it + if (++empties > 5) break; // fresh conn, no bytes after ~5 ms — give up + platform::delayMs(1); } } @@ -82,7 +94,12 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { buf[totalRead] = 0; auto* req = reinterpret_cast(buf); - // If we have headers but body might still be arriving, read more + // If headers arrived but the body is still in flight, read the rest. read() is + // non-blocking (-1 = nothing pending yet), so the body can land a TCP segment after the + // headers — wait briefly between empty reads (the same bounded retry as the header + // phase) instead of breaking on the first -1, which would route a TRUNCATED body into + // the permissive JSON helpers (a silent partial control write). If the full declared + // body still hasn't arrived within the budget, reject with 400 rather than process it. auto* headerEnd = std::strstr(req, "\r\n\r\n"); if (headerEnd) { auto* clh = std::strstr(req, "Content-Length:"); @@ -90,12 +107,20 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { int contentLen = std::atoi(clh + 15); int headerSize = static_cast(headerEnd + 4 - req); int bodyNeeded = headerSize + contentLen; - while (totalRead < bodyNeeded && totalRead < static_cast(sizeof(buf) - 1)) { + if (bodyNeeded > static_cast(sizeof(buf) - 1)) + bodyNeeded = static_cast(sizeof(buf) - 1); // cap to buffer + for (int empties = 0; totalRead < bodyNeeded;) { int n = conn.read(buf + totalRead, sizeof(buf) - 1 - totalRead); - if (n > 0) totalRead += n; - else break; + if (n > 0) { totalRead += n; empties = 0; } + else if (n == 0) break; // peer closed + else { if (++empties > 50) break; platform::delayMs(1); } // ~50 ms for the body } buf[totalRead] = 0; + if (totalRead < bodyNeeded) { // body never fully arrived + sendResponse(conn, 400, "application/json", + "{\"error\":\"incomplete request body\"}"); + return; + } } } @@ -136,6 +161,17 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { else if (std::strcmp(path, "/api/state") == 0) serveState(conn); else if (std::strcmp(path, "/api/system") == 0) serveSystem(conn); else if (std::strcmp(path, "/api/types") == 0) serveTypes(conn); + // WLED-compatibility shim: the native WLED apps (and Home Assistant's WLED + // integration) discover a device via mDNS `_wled._tcp` then VALIDATE it by + // GETting /json/info and checking it's WLED-shaped. Serving a minimal + // WLED-compatible info makes a projectMM device appear in those apps — and is a + // useful independent cross-check that our mDNS advertise resolves. + else if (std::strcmp(path, "/json/info") == 0) serveWledInfo(conn); + // WLED state + the combined state+info (`/json/si`) the app reads for its device + // card: on/off, brightness, and the segment's primary colour (which the app uses + // 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); else sendResponse(conn, 404, "text/plain", "Not found"); } else if (std::strcmp(method, "POST") == 0) { // POST /api/modules//move with body {"to":N}. @@ -177,6 +213,11 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { nameBuf[nameLen] = 0; handleReplaceModule(conn, nameBuf, body); } + } else if (std::strcmp(path, "/json/state") == 0 && body) { + // WLED-compatibility: the native WLED app POSTs {on,bri,…} here to control the + // device. We map it onto the Drivers brightness control so the app's on/off + + // brightness slider drive the real output. + handleWledState(conn, body); } else if (std::strcmp(path, "/api/reboot") == 0) { handleReboot(conn); } else if (std::strcmp(path, "/api/firmware/url") == 0 && body) { @@ -586,6 +627,160 @@ void HttpServerModule::serveSystem(platform::TcpConnection& conn) { sink.flush(); } +// WLED-compatibility `/json/info` — the subset of WLED's info object the native WLED +// apps + Home Assistant validate when they probe a device they discovered via +// `_wled._tcp`. The clients gate on a WLED-shaped identity: `brand:"WLED"`, a real +// `vid` (build id; they reject 0), a WLED-major `ver`, and `leds.count`. We declare +// `brand:"WLED"` because the apps key on it — the same thing WLED-MM (the MoonModules +// WLED fork) does — while `product:"MoonModules"` says what this actually is. We speak +// WLED's info shape to interoperate, not to impersonate. Built fresh against WLED's +// public JSON, not copied. (Reference real WLED carries far more; this is the trimmed, +// known-sufficient field set — see docs/moonmodules/core/HttpServerModule.md.) +void HttpServerModule::serveWledInfo(platform::TcpConnection& conn) { + const char* header = + "HTTP/1.1 200 OK\r\n" + "Content-Type: application/json\r\n" + "Connection: close\r\n" + "Access-Control-Allow-Origin: *\r\n" + "\r\n"; + 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); + + // 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` + // (object), and `wifi` (object) — a missing one fails the JSON parse and the device is + // silently dropped. `DeviceFirstContactService.kt` additionally rejects a device whose + // body `mac` is empty. Every other field in the model is nullable. So this is the + // minimal object the native app accepts: name + leds{} + wifi{} + a non-empty mac. The + // inner Leds/Wifi fields are themselves all nullable, so empty `{}` objects parse — we + // send a real `mac` and otherwise the smallest shapes that satisfy the parser. `brand`/ + // `product` identify us as the MoonModules WLED-compatible product (interoperate, not + // impersonate). Confirmed on the bench: projectMM devices list in the WLED native app. + JsonSink sink(conn); + writeWledInfoBody(sink, name, mac); + sink.flush(); +} + +// The WLED info object, written into an open sink (no HTTP header). Shared by +// /json/info and the `info` half of /json/si. +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). + sink.appendf(",\"mac\":\"%02x%02x%02x%02x%02x%02x\"," + "\"leds\":{\"count\":1},\"wifi\":{\"rssi\":-50,\"signal\":100}," + "\"brand\":\"WLED\",\"product\":\"MoonModules\"}", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); +} + +// 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. +uint8_t HttpServerModule::driversBrightness() { + MoonModule* d = findModuleByName("Drivers"); + if (!d) return 0; + const ControlList& cl = d->controls(); + for (uint8_t i = 0; i < cl.count(); i++) { + const ControlDescriptor& c = cl[i]; + if (c.ptr && c.type == ControlType::Uint8 && std::strcmp(c.name, "brightness") == 0) + return *static_cast(c.ptr); + } + return 0; +} + +// 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). +void HttpServerModule::writeWledStateBody(JsonSink& sink) { + const uint8_t bri = driversBrightness(); + 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]]}]}", + bri > 0 ? "true" : "false", bri, rgb[0], rgb[1], rgb[2]); +} + +void HttpServerModule::serveWledState(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)); + JsonSink sink(conn); + writeWledStateBody(sink); + 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 = + "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 = "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); + + JsonSink sink(conn); + sink.appendf("{\"state\":"); + writeWledStateBody(sink); + sink.appendf(",\"info\":"); + writeWledInfoBody(sink, name, mac); + sink.appendf("}"); + sink.flush(); +} + +// Apply a WLED state-set body ({on?, bri?}) to the Drivers brightness control through the +// shared apply-core (the same path /api/control and Improv APPLY_OP use). `on:false` → 0; +// `on:true` with no `bri` → restore a visible default; `bri:N` → set N. Shared by the HTTP +// POST /json/state handler and the inbound-WebSocket path (the app uses both channels). +void HttpServerModule::applyWledState(const char* body) { + int bri = -1; + if (mm::json::hasKey(body, "bri")) bri = mm::json::parseInt(body, "bri"); + if (mm::json::hasKey(body, "on")) { + const bool on = mm::json::parseBool(body, "on"); + if (!on) bri = 0; + else if (bri < 0) bri = driversBrightness() > 0 ? -1 : 128; // turn on → visible default + } + if (bri >= 0) { + if (bri > 255) bri = 255; + char valueJson[32]; + std::snprintf(valueJson, sizeof(valueJson), "{\"value\":%d}", bri); + applySetControl("Drivers", "brightness", valueJson); + } +} + +// POST /json/state — the WLED app's HTTP control channel (its system quick-tiles + Home +// Assistant). Apply, then echo the resulting state (the app expects a State response). +void HttpServerModule::handleWledState(platform::TcpConnection& conn, const char* body) { + applyWledState(body); + serveWledState(conn); +} + void HttpServerModule::writeModuleMetricsJson(JsonSink& sink, MoonModule* mod, bool& first) { if (!mod) return; sink.appendf( @@ -1112,6 +1307,88 @@ void HttpServerModule::pushStateToWebSockets() { ws.close(); } } + + // Also push a WLED-shaped {state, info} frame. The native WLED app connects to this + // same /ws and reads live state (colour, brightness, on/off) from a DeviceStateInfo + // message — it has no /json/si GET. Our own UI ignores this frame (its JS keys on + // `modules`); the WLED app ignores our module frame (its Moshi keys on `state`/`info`). + // Two small frames, each consumer parses its own — no client needs to know about the + // other. This is what makes the device's card show the live colour + a working slider. + pushWledStateToWebSockets(); +} + +// Build and push the WLED {state, info} object to every WS client. Shares the same body +// writers as /json/si. +void HttpServerModule::pushWledStateToWebSockets() { + bool hasClients = false; + 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); + + JsonSink sink; + sink.appendf("{\"state\":"); + writeWledStateBody(sink); + sink.appendf(",\"info\":"); + writeWledInfoBody(sink, name, mac); + sink.appendf("}"); + + for (auto& ws : wsClients_) { + if (!ws.valid()) continue; + if (!sendWsTextFrame(ws, sink.data(), static_cast(sink.size()))) ws.close(); + } +} + +// Read one pending WS frame per client and, if it's a WLED state-set ({on}/{bri}), apply +// it to Drivers. The native WLED app's slider/toggle SEND state over /ws (sendState), +// not via HTTP POST, so this is the inbound half of the control path. Client→server +// frames are always MASKED (RFC 6455 §5.3): we unmask in place before parsing. Only the +// small text frame we care about is handled; we ignore continuation/binary/control frames +// (a ping/close is rare on this short-lived control socket and harmless to skip). +void HttpServerModule::pollWledStateFromWebSockets() { + for (auto& ws : wsClients_) { + if (!ws.valid()) continue; + uint8_t f[512]; + int n = ws.read(f, sizeof(f)); // non-blocking (read() returns -1 if nothing) + if (n < 6) continue; // a masked text frame is ≥6 bytes + // A fast slider drag can land MULTIPLE small {on,bri} frames in one read; walk every + // complete masked text frame in the chunk so none is dropped (apply each in order → + // the last value wins, matching the drag). The app's frames are tiny single-segment + // text frames, so partial-frame reassembly across reads isn't needed; a trailing + // partial frame is simply left for the next poll. + size_t off = 0; + const size_t total = static_cast(n); + while (off + 6 <= total) { + const uint8_t* fr = f + off; + const uint8_t opcode = fr[0] & 0x0f; + const bool masked = fr[1] & 0x80; + size_t len = fr[1] & 0x7f; + size_t hdr = 2; + if (len == 126) { + if (off + 4 > total) break; + len = (size_t(fr[2]) << 8) | fr[3]; hdr = 4; + } else if (len == 127) { + break; // >64 KB control message: not ours, stop + } + const size_t frameLen = hdr + 4 + len; // header + mask key + payload (client = masked) + if (!masked || off + frameLen > total) break; // incomplete/unmasked — leave for later + if (opcode == 0x1 && len < 200) { // a text frame small enough to be a state-set + const uint8_t* mask = fr + hdr; + char body[200]; + for (size_t i = 0; i < len; i++) body[i] = static_cast(fr[hdr + 4 + i] ^ mask[i & 3]); + body[len] = 0; + if (mm::json::hasKey(body, "on") || mm::json::hasKey(body, "bri")) + applyWledState(body); + } + off += frameLen; + } + } } bool HttpServerModule::sendWsTextFrame(platform::TcpConnection& conn, const char* data, int len) { diff --git a/src/core/HttpServerModule.h b/src/core/HttpServerModule.h index 5778714b..498b125e 100644 --- a/src/core/HttpServerModule.h +++ b/src/core/HttpServerModule.h @@ -179,6 +179,18 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { // System metrics // ----------------------------------------------------------------------- void serveSystem(platform::TcpConnection& conn); + // WLED-compatibility shim — see the /json/info route + the impl rationale. /json/info + // lists the device; /json/state + /json/si carry on/brightness/colour for the card; + // POST /json/state maps the app's toggle + slider onto Drivers brightness. + void serveWledInfo(platform::TcpConnection& conn); + void serveWledState(platform::TcpConnection& conn); + void serveWledStateInfo(platform::TcpConnection& conn); + void handleWledState(platform::TcpConnection& conn, const char* body); + void applyWledState(const char* body); // {on,bri} → Drivers brightness (HTTP + WS) + void pollWledStateFromWebSockets(); // read app's slider/toggle sent over /ws + void writeWledInfoBody(JsonSink& sink, const char* name, const uint8_t mac[6]); + void writeWledStateBody(JsonSink& sink); + uint8_t driversBrightness(); void writeModuleMetricsJson(JsonSink& sink, MoonModule* mod, bool& first); // ----------------------------------------------------------------------- @@ -201,6 +213,7 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { // ----------------------------------------------------------------------- void handleWebSocketUpgrade(platform::TcpConnection& conn, const char* req); void pushStateToWebSockets(); + void pushWledStateToWebSockets(); // WLED-app {state,info} frame on /ws (see impl) static bool sendWsTextFrame(platform::TcpConnection& conn, const char* data, int len); // Write the whole span to one client via repeated non-blocking writeSome; close it + return // false if it can't all go (a stuck/too-slow client). The push primitive behind begin/push/end. diff --git a/src/core/I2cScanModule.h b/src/core/I2cScanModule.h new file mode 100644 index 00000000..5bf6f227 --- /dev/null +++ b/src/core/I2cScanModule.h @@ -0,0 +1,97 @@ +#pragma once + +// I2cScanModule — a diagnostic that scans an I2C bus and reports which device +// addresses ACK (the standard `i2cdetect`). Domain-neutral: any I2C bring-up +// (an audio codec, a sensor, a port expander) uses it to confirm wiring and +// read off a device's address. Pressing `scan` probes the bus on the `sda` / +// `scl` pins and lists the 7-bit addresses found in `result`. +// +// Same shape as DevicesModule (a momentary `scan` button → results), one rung +// simpler: the bus is local (no persisted list, no live age-out), so a single +// read-only `result` string suffices instead of a ListSource. +// +// The pins are controls (defaulting unset) so each board sets its bus pins in +// docs/install/deviceModels.json — the same per-board pin-config pattern as the +// driver/audio modules. The actual probe is platform::i2cScan (platform.h), a +// self-contained seam that opens a temporary bus, scans, and tears it down, so +// the diagnostic never fights a bus another driver owns. + +#include "core/MoonModule.h" +#include "platform/platform.h" // i2cScan + +#include +#include +#include // strcmp + +namespace mm { + +class I2cScanModule : public MoonModule { +public: + // A diagnostic, like FirmwareUpdateModule / DevicesModule — keeps its + // controls and the scan action available regardless of the `enabled` toggle. + bool respectsEnabled() const override { return false; } + + ModuleRole role() const override { return ModuleRole::Peripheral; } + + void onBuildControls() override { + controls_.addPin("sda", sda_); + controls_.addPin("scl", scl_); + controls_.addButton("scan"); + controls_.addReadOnly("result", resultStr_, sizeof(resultStr_)); + MoonModule::onBuildControls(); + } + + void onUpdate(const char* controlName) override { + if (std::strcmp(controlName, "scan") == 0) scan(); + } + +private: + // Default to GPIO21/22 — the Arduino-ESP32 core's default I2C pair, the pins a + // contributor expects to try first on a classic ESP32. They're a *convention*, + // not fixed hardware (I2C routes through the GPIO matrix to any pins), so they + // pre-fill the control as a sensible starting point the user edits. A board + // with a FIXED bus (the S31 codec, the P4) overrides them via its catalog entry. + int8_t sda_ = 21; + int8_t scl_ = 22; + char resultStr_[64] = ""; // space-separated hex addresses, e.g. "0x18 0x3c" + char statusBuf_[40] = "idle"; + + void scan() { + if (sda_ < 0 || scl_ < 0) { + resultStr_[0] = '\0'; + setStatus("set sda + scl pins first", Severity::Warning); + return; + } + uint8_t found[kMaxAddrs]; + const size_t n = platform::i2cScan(static_cast(sda_), + static_cast(scl_), + found, kMaxAddrs); + if (n == platform::kI2cBusUnavailable) { + // The bus is held by another driver (e.g. the ES8311 codec while + // AudioModule is active) — say so instead of a misleading "0 found". + resultStr_[0] = '\0'; + setStatus("bus in use — free the I2C driver, then scan", Severity::Warning); + markDirty(); + return; + } + // Build the "0x18 0x3c …" result string, truncating cleanly if the buffer + // fills (more devices than fit is unusual on one bus, but stay bounded). + int pos = 0; + for (size_t i = 0; i < n; i++) { + const int w = std::snprintf(resultStr_ + pos, sizeof(resultStr_) - pos, + "%s0x%02x", i ? " " : "", found[i]); + if (w <= 0 || pos + w >= static_cast(sizeof(resultStr_))) break; + pos += w; + } + if (n == 0) resultStr_[0] = '\0'; + + std::snprintf(statusBuf_, sizeof(statusBuf_), "%u device%s found", + static_cast(n), n == 1 ? "" : "s"); + setStatus(statusBuf_); + markDirty(); // push the updated result + status to the UI + } + + static constexpr size_t kMaxAddrs = 16; // plenty for one bus +}; + +} // namespace mm diff --git a/src/core/MoonModule.h b/src/core/MoonModule.h index 3e358051..98b4db71 100644 --- a/src/core/MoonModule.h +++ b/src/core/MoonModule.h @@ -141,6 +141,12 @@ class MoonModule { // Don't add it pre-emptively; no module needs the distinction today. virtual void onBuildState() { for (uint8_t i = 0; i < childCount_; i++) children_[i]->onBuildState(); } + // Read this module's first output light as RGB into out[3], returning true if it has + // one. Domain-neutral seam (core declares it, the output-owning module overrides): + // the WLED-compatibility shim uses it to tint the app's device card with the live + // first-LED colour. Default: no output → false. + virtual bool firstOutputRgb(uint8_t /*out*/[3]) const { return false; } + const char* name() const { return name_; } void setName(const char* n) { if (!n) { name_[0] = 0; return; } diff --git a/src/core/WledPacket.h b/src/core/WledPacket.h new file mode 100644 index 00000000..9ded679f --- /dev/null +++ b/src/core/WledPacket.h @@ -0,0 +1,110 @@ +#pragma once + +// WLED presence packet — the 44-byte header a WLED device broadcasts to 255.255.255.255 +// on UDP 65506 for discovery (NOT sync: a WLED that receives one lists the sender, it does +// not mirror its state — sync/control is a separate protocol on a port WLED never shares). +// +// projectMM uses this in two directions, both discovery-only: +// - PARSE inbound packets to discover real WLED devices on the LAN (WledPlugin). +// - BUILD an outbound packet so projectMM devices discover each other (MmPlugin presence) +// AND so a WLED / WLED app browsing 65506 can also list us. +// +// Wire layout (little-endian fields, packed, exactly 44 bytes) — observed from a live WLED +// and cross-checked against the field names in MoonLight's ModuleDevices.h; re-implemented +// fresh here, not copied. WLED validates token==255 && id==1 && ip0==its-own-subnet. +// +// Prior art / credit: the WLED native-app discovery contract this interoperates with was +// reverse-engineered from Christophe Gagnier's (@Moustachauve) WLED-Android client +// (github.com/Moustachauve/WLED-Android) — its `DeviceDiscovery`/`Info`/`State` models +// told us exactly which fields the app requires. The 65506 packet shape cross-references +// MoonLight's `ModuleDevices.h`. We carry those ideas forward in our own code. +// +// off size field +// 0 1 token = 255 (magic) +// 1 1 id = 1 (magic / protocol) +// 2-5 4 ip0..ip3 sender IPv4 octets +// 6-37 32 name null-padded hostname +// 38 1 type low 7 bits = board kind; bit 7 (0x80) = lights on +// 39 1 insId last IP octet (WLED uses it as an instance index) +// 40-43 4 version numeric build date (informational) + +#include +#include +#include + +namespace mm { + +struct WledPacket { + static constexpr uint16_t kPort = 65506; + static constexpr size_t kSize = 44; + static constexpr uint8_t kToken = 255; + static constexpr uint8_t kId = 1; + static constexpr size_t kNameOff = 6; + static constexpr size_t kNameMax = 32; + static constexpr size_t kTypeOff = 38; + + // True if `data`/`len` is a valid WLED presence header (magic bytes + full length). + // Defensive: any short or non-matching datagram returns false, never reads OOB. + static bool isValid(const uint8_t* data, size_t len) { + // Exactly kSize: a presence packet is a fixed 44-byte header. A longer datagram on + // this port is something else (e.g. a WLED realtime/sync packet), not presence. + return data && len == kSize && data[0] == kToken && data[1] == kId; + } + + // Extract the null-padded name (bytes 6..37) into `out` (NUL-terminated). Caller-sized + // buffer; truncates to outCap-1. Assumes isValid() already passed. + static void readName(const uint8_t* data, char* out, size_t outCap) { + if (!out || outCap == 0) return; + size_t n = 0; + for (; n < kNameMax && n < outCap - 1; n++) { + uint8_t c = data[kNameOff + n]; + if (c == 0) break; + out[n] = static_cast(c); + } + out[n] = 0; + } + + // Build a 44-byte presence packet into `out` (must be >= kSize). `ip` = our IPv4 + // octets (ip0 must be our real first octet or WLED's subnet check rejects us), + // `name` = deviceName, `boardType` = the low-7-bit board kind, `lightsOn` sets bit 7. + // Pure presence — carries no command, so a receiving WLED only lists us. + static void build(uint8_t* out, const uint8_t ip[4], const char* name, + uint8_t boardType, bool lightsOn) { + std::memset(out, 0, kSize); + out[0] = kToken; + out[1] = kId; + out[2] = ip[0]; out[3] = ip[1]; out[4] = ip[2]; out[5] = ip[3]; + if (name) { + size_t n = std::strlen(name); + if (n > kNameMax) n = kNameMax; // bytes 6..37, null-padded by the memset + std::memcpy(out + kNameOff, name, n); + } + out[kTypeOff] = static_cast((boardType & 0x7f) | (lightsOn ? 0x80 : 0)); + out[39] = ip[3]; // insId = last IP octet, matching WLED's convention + // version (40..43) left 0 — informational only, no validator reads it. + } + + // projectMM marker. A projectMM device broadcasts a WLED-VALID packet (so WLED apps + // list it too), but a peer projectMM device must tell "this is a projectMM peer" from + // "a generic WLED". We stamp a sentinel into the version field (bytes 40–43) — a field + // no WLED validator reads, so it stays WLED-valid while uniquely marking us. ASCII "MM" + // + a small protocol version, little-endian. + static constexpr size_t kMarkerOff = 40; + static constexpr uint32_t kMmMarker = 0x014d4d00u; // 0x00 'M' 'M' 0x01 → bytes: 00 4D 4D 01 + + static void stampMmMarker(uint8_t* out) { + out[kMarkerOff + 0] = (kMmMarker >> 0) & 0xff; + out[kMarkerOff + 1] = (kMmMarker >> 8) & 0xff; + out[kMarkerOff + 2] = (kMmMarker >> 16) & 0xff; + out[kMarkerOff + 3] = (kMmMarker >> 24) & 0xff; + } + + static bool hasMmMarker(const uint8_t* data, size_t len) { + if (len < kSize) return false; + uint32_t v = uint32_t(data[kMarkerOff]) | (uint32_t(data[kMarkerOff + 1]) << 8) + | (uint32_t(data[kMarkerOff + 2]) << 16) | (uint32_t(data[kMarkerOff + 3]) << 24); + return v == kMmMarker; + } +}; + +} // namespace mm diff --git a/src/light/drivers/Drivers.h b/src/light/drivers/Drivers.h index 5709039b..5c17e028 100644 --- a/src/light/drivers/Drivers.h +++ b/src/light/drivers/Drivers.h @@ -25,6 +25,16 @@ class DriverBase : public MoonModule { // Drivers container hands it one Layer for dimensions regardless of how many // layers feed the output buffer. void setLayer(Layer* layer) { layer_ = layer; } + + // The configured window (start light, count; count 0 = to end of buffer). + // Public for tests pinning the slice arithmetic; production reads via + // windowSlice(). See start_/count_ below. + uint16_t windowStart() const { return start_; } + uint16_t windowCount() const { return count_; } + // Set the window directly (the UI sets it via the start/count controls; this + // is for code-wiring a driver's slice and for tests). Takes effect on the next + // config parse / loop, like a control edit. + void setWindow(uint16_t start, uint16_t count) { start_ = start; count_ = count; } // The active Layer this driver reads dimensions from — null when no Layer is // wired (e.g. the last Layer was deleted). Drivers must tolerate null here. Layer* layer() const { return layer_; } @@ -51,6 +61,45 @@ class DriverBase : public MoonModule { protected: Layer* layer_ = nullptr; + // --- Shared source-buffer window (start, count) --------------------------- + // Every driver reads the SAME shared source buffer (Drivers hands the one + // Buffer* to each child) and outputs a contiguous slice of it: lights + // [start_, start_+count_). This is how light distribution is made *explicit* + // and order-independent — a second driver on a different slice (e.g. an + // onboard status LED at index 0, the main strip from index 1) just sets its + // own window, rather than the buffer being split by driver order. `count_`==0 + // means "the rest of the buffer from start_" (the common whole-buffer case). + // NetworkSendDriver's universe maps onto the same window; the LED drivers' + // pins/ledsPerPin distribute lights *within* the window. + uint16_t start_ = 0; + uint16_t count_ = 0; // 0 = to end of buffer + + // Add the two window controls — call from a driver's onBuildControls(). Kept + // a helper (not auto-added) so a driver opts in by calling it where its other + // controls go, keeping control *order* in the driver's hands. + void addWindowControls() { + controls_.addUint16("start", start_); + controls_.addUint16("count", count_); + } + + // True if `name` is one of the window controls — a driver folds this into its + // controlChangeTriggersBuildState() so editing the slice re-runs its config. + static bool isWindowControl(const char* name) { + return std::strcmp(name, "start") == 0 || std::strcmp(name, "count") == 0; + } + + // Resolve the window against a buffer of `bufN` lights: writes the clamped + // first light to `outStart` and the slice length to `outLen` (0 if the window + // starts past the end). The textbook [start, start+count) clamp — every + // driver calls this instead of reading from light 0. + void windowSlice(nrOfLightsType bufN, nrOfLightsType& outStart, + nrOfLightsType& outLen) const { + outStart = start_ < bufN ? start_ : bufN; + const nrOfLightsType avail = static_cast(bufN - outStart); + outLen = (count_ == 0 || count_ > avail) ? avail + : static_cast(count_); + } + // --- Shared status-string lifecycle for the physical LED drivers (RMT / LCD / // Parlio). They report two kinds of transient status that must clear cleanly // without stomping an unrelated status set by something else: @@ -195,6 +244,22 @@ class Drivers : public MoonModule { MoonModule::onBuildState(); } + // First output light as RGB — the live colour of pixel 0, read from whichever buffer + // loop() is currently driving (the composited outputBuffer_ when allocated, else the + // first enabled layer's own buffer — the zero-copy single-layer path). The WLED shim + // tints the app's device card with this. RGB is the buffer's logical channel order + // (0,1,2); the per-strip wire reorder is applied later by the physical drivers, not here. + bool firstOutputRgb(uint8_t out[3]) const override { + const Buffer* src = nullptr; + if (outputBuffer_.data()) src = &outputBuffer_; + else if (Layer* l = layers_ ? layers_->firstEnabledLayer() : layer_; l && l->buffer().data()) + src = &l->buffer(); + if (!src || src->count() == 0 || src->channelsPerLight() < 3) return false; + const uint8_t* p = src->data(); + out[0] = p[0]; out[1] = p[1]; out[2] = p[2]; + return true; + } + void loop() override { // Composite into outputBuffer_ when one is allocated (≥2 enabled layers, // or a single layer with a LUT — see onBuildState). A null data_ means diff --git a/src/light/drivers/NetworkSendDriver.h b/src/light/drivers/NetworkSendDriver.h index 502e49c8..50fcc548 100644 --- a/src/light/drivers/NetworkSendDriver.h +++ b/src/light/drivers/NetworkSendDriver.h @@ -37,19 +37,27 @@ class NetworkSendDriver : public DriverBase { uint8_t ip[4] = {255, 255, 255, 255}; uint8_t protocol = 0; // index into kProtocolOptions uint16_t universeStart = 0; // first universe (ArtNet/E1.31; DDP is byte-addressed) - uint16_t lightCount = 0; // lights to send (0 = the whole buffer); >0 sends the FIRST N, - // so a sink can cover just its slice (e.g. some lights to LEDs, - // the rest to ArtNet) instead of every light. uint8_t fps = 50; + // The buffer slice this sink sends is the shared DriverBase window: start_ + + // count_ ("count" 0 = the whole buffer from start). `universe_start` is the + // separate *protocol* offset (which DMX universe the slice maps onto), not a + // buffer offset — the two are orthogonal. void onBuildControls() override { controls_.addSelect("protocol", protocol, kProtocolOptions, kProtocolCount); controls_.addIPv4("ip", ip); controls_.addUint16("universe_start", universeStart); - controls_.addUint16("light_count", lightCount); + addWindowControls(); // start / count — the slice of the shared buffer this sink sends controls_.addUint8("fps", fps, 1, 120); } + // A start/count change resizes the window this sink sends; route it through the + // onBuildState sweep so resizeCorrected() re-sizes corrected_ for the new slice — + // otherwise growing the window past the old corrected_ silently drops to passthrough. + bool controlChangeTriggersBuildState(const char* name) const override { + return isWindowControl(name); + } + void setup() override { socket_.open(); // E1.31 wants a stable per-device component id; derive it from the MAC @@ -117,11 +125,11 @@ class NetworkSendDriver : public DriverBase { // earlier in-loop allocate had if the allocation itself failed. const uint8_t* data; size_t totalBytes; - // Send the first light_count lights (0 = the whole buffer), so this sink covers only its - // slice instead of every light — and so a frame isn't packed/sent for lights it doesn't own. - const nrOfLightsType bufLights = sourceBuffer_->count(); - const nrOfLightsType nLights = - (lightCount > 0 && lightCount < bufLights) ? lightCount : bufLights; + // Send this sink's window slice [start, start+count) only (count 0 = the + // whole buffer from start), so it covers just its lights — and a frame + // isn't packed/sent for lights it doesn't own. winStart is the first light. + nrOfLightsType winStart, nLights; + windowSlice(sourceBuffer_->count(), winStart, nLights); // Three guards before applying correction: (a) correction wired, // (b) corrected_ has the row count we need, (c) corrected_'s // per-light stride is at least outChannels — otherwise dst + i * @@ -140,15 +148,17 @@ class NetworkSendDriver : public DriverBase { const uint8_t srcCh = sourceBuffer_->channelsPerLight(); uint8_t* dst = corrected_.data(); for (nrOfLightsType i = 0; i < nLights; i++) { - correction_->apply(src + i * srcCh, dst + i * outCh); + // Read the windowed light (slice starts at winStart); pack densely. + correction_->apply(src + (winStart + i) * srcCh, dst + i * outCh); } data = dst; totalBytes = static_cast(nLights) * outCh; } else { - // Passthrough (no correction): honour the same light_count cap as the corrected path, - // so a sliced sink doesn't fall back to sending the whole buffer. - data = sourceBuffer_->data(); - totalBytes = static_cast(nLights) * sourceBuffer_->channelsPerLight(); + // Passthrough (no correction): honour the same window as the corrected + // path — point at the slice start so a sliced sink sends only its lights. + const uint8_t srcCh = sourceBuffer_->channelsPerLight(); + data = sourceBuffer_->data() + static_cast(winStart) * srcCh; + totalBytes = static_cast(nLights) * srcCh; } // Send the whole frame in one burst — receivers expect a complete @@ -231,7 +241,11 @@ class NetworkSendDriver : public DriverBase { // when nothing is wired yet, or when the existing allocation already fits. void resizeCorrected() { if (!correction_ || !sourceBuffer_) return; - const nrOfLightsType n = sourceBuffer_->count(); + // Size for the window slice this sender actually transmits, not the whole + // frame — a sink covering 64 of a 16K-light buffer reserves 64. The same + // windowSlice() the send loop uses, so the buffers stay in lock-step. + nrOfLightsType winStart, n; + windowSlice(sourceBuffer_->count(), winStart, n); const uint8_t ch = correction_->outChannels; if (n == 0 || ch == 0) return; if (corrected_.count() >= n && corrected_.channelsPerLight() >= ch) return; diff --git a/src/light/drivers/ParallelLedDriver.h b/src/light/drivers/ParallelLedDriver.h index 9485647a..8c99554d 100644 --- a/src/light/drivers/ParallelLedDriver.h +++ b/src/light/drivers/ParallelLedDriver.h @@ -67,6 +67,7 @@ class ParallelLedDriver : public DriverBase { int8_t loopbackRxPin = -1; void onBuildControls() override { + addWindowControls(); // start / count — the slice of the shared buffer this driver outputs controls_.addText("pins", pins, sizeof(pins)); controls_.addText("ledsPerPin", ledsPerPin, sizeof(ledsPerPin)); derived()->addBusControls(); // i80 adds clockPin/dcPin here; Parlio none @@ -80,6 +81,7 @@ class ParallelLedDriver : public DriverBase { bool controlChangeTriggersBuildState(const char* name) const override { return std::strcmp(name, "pins") == 0 || std::strcmp(name, "ledsPerPin") == 0 + || isWindowControl(name) || derived()->busControlTriggersBuild(name); // clockPin/dcPin on i80 } @@ -143,7 +145,9 @@ class ParallelLedDriver : public DriverBase { for (uint8_t lane = 0; lane < laneCount_; lane++) { if (row >= laneCounts_[lane]) continue; // short strand: idle LOW mask |= static_cast(1u << lane); - correction_->apply(src + (laneStart_[lane] + row) * srcCh, + // winStart_ shifts this driver's whole slice; laneStart_ is the + // per-lane offset within it. + correction_->apply(src + (winStart_ + laneStart_[lane] + row) * srcCh, wire + lane * 4); } encodeWs2812LcdSlots(wire, mask, outCh, out); @@ -179,6 +183,8 @@ class ParallelLedDriver : public DriverBase { uint16_t laneList_[kMaxLanes] = {}; nrOfLightsType laneCounts_[kMaxLanes] = {}; nrOfLightsType laneStart_[kMaxLanes] = {}; + nrOfLightsType winStart_ = 0; // first source-buffer light this driver reads (the window) + nrOfLightsType winLen_ = 0; // window length (lights), clamped to the buffer uint8_t laneCount_ = 0; nrOfLightsType maxLaneLights_ = 0; size_t frameBytes_ = 0; @@ -222,8 +228,10 @@ class ParallelLedDriver : public DriverBase { if (!err && n != kMaxLanes) err = "LCD bus needs exactly 8 pins"; } if (!err) { - const nrOfLightsType total = sourceBuffer_ ? sourceBuffer_->count() : 0; - err = assignCounts(ledsPerPin, n, total, laneCounts_); + // Distribute over this driver's window slice, not the whole buffer. + const nrOfLightsType bufN = sourceBuffer_ ? sourceBuffer_->count() : 0; + windowSlice(bufN, winStart_, winLen_); + err = assignCounts(ledsPerPin, n, winLen_, laneCounts_); } if (err) { setConfigErr(err); diff --git a/src/light/drivers/RmtLedDriver.h b/src/light/drivers/RmtLedDriver.h index d21eb9f2..c6798c86 100644 --- a/src/light/drivers/RmtLedDriver.h +++ b/src/light/drivers/RmtLedDriver.h @@ -96,6 +96,7 @@ class RmtLedDriver : public DriverBase { // buffer from the same two text controls. void onBuildControls() override { + addWindowControls(); // start / count — the slice of the shared buffer this driver outputs controls_.addText("pins", pins, sizeof(pins)); controls_.addText("ledsPerPin", ledsPerPin, sizeof(ledsPerPin)); controls_.addBool("loopbackTest", loopbackTest); @@ -117,7 +118,8 @@ class RmtLedDriver : public DriverBase { // channels (live, not reboot-to-apply), so the pipeline-wide onBuildState // sweep runs and parseConfig()/reinit() pick up the new lists. bool controlChangeTriggersBuildState(const char* name) const override { - return std::strcmp(name, "pins") == 0 || std::strcmp(name, "ledsPerPin") == 0; + return std::strcmp(name, "pins") == 0 || std::strcmp(name, "ledsPerPin") == 0 + || isWindowControl(name); } // React to a control change (runs off the render loop, in the HTTP/API @@ -201,8 +203,10 @@ class RmtLedDriver : public DriverBase { // buffer: a strand config of e.g. 64 leds/pin on a 16K-light grid drives 64, so encoding // all 16384 would burn ~100× the work the output needs (the rest is never clocked out). // Bounded by the buffer too, in case config outruns the current frame. - const nrOfLightsType bufN = sourceBuffer_->count(); - const nrOfLightsType n = txLightCount_ < bufN ? txLightCount_ : bufN; + // Encode within this driver's window only. winLen_ is the slice length; + // txLightCount_ (Σ pinCounts_) is what the pins clock out — n is the min, + // so a window smaller than the configured pin total never reads past it. + const nrOfLightsType n = txLightCount_ < winLen_ ? txLightCount_ : winLen_; const uint8_t outCh = correction_->outChannels; // Same defensive guard ArtNet uses: skip rather than overrun if the // symbol buffer is stale (e.g. correction swapped without a resize). @@ -220,7 +224,8 @@ class RmtLedDriver : public DriverBase { size_t s = 0; uint8_t wire[4]; for (nrOfLightsType i = 0; i < n; i++) { - correction_->apply(src + i * srcCh, wire); + // Read the windowed light: this driver's slice starts at winStart_. + correction_->apply(src + (winStart_ + i) * srcCh, wire); encodeWs2812Symbols(wire, outCh, t0h, t1h, period, symbols_ + s); s += static_cast(outCh) * 8; } @@ -277,6 +282,8 @@ class RmtLedDriver : public DriverBase { nrOfLightsType pinCounts_[kMaxPins] = {}; // lights per pin (slice lengths) size_t pinOffsets_[kMaxPins] = {}; // slice start in symbols_, words nrOfLightsType txLightCount_ = 0; // Σ pinCounts_ — lights actually transmitted/encoded + nrOfLightsType winStart_ = 0; // first source-buffer light this driver reads (the window) + nrOfLightsType winLen_ = 0; // window length (lights), clamped to the buffer uint8_t pinCount_ = 0; // 0 = idle (parse error / no pins) bool inited_ = false; // all-or-nothing across the pins uint32_t* symbols_ = nullptr; // owned; one word per WS2812 data bit @@ -321,8 +328,11 @@ class RmtLedDriver : public DriverBase { uint8_t n = 0; const char* err = parsePinList(pins, pinList_, maxPinsForTarget(), n); if (!err) { - const nrOfLightsType total = sourceBuffer_ ? sourceBuffer_->count() : 0; - err = assignCounts(ledsPerPin, n, total, pinCounts_); + // Distribute over the driver's window slice, not the whole buffer, so + // ledsPerPin's "rest" only fills this driver's [start, start+count). + const nrOfLightsType bufN = sourceBuffer_ ? sourceBuffer_->count() : 0; + windowSlice(bufN, winStart_, winLen_); + err = assignCounts(ledsPerPin, n, winLen_, pinCounts_); } if (err) { setConfigErr(err); @@ -347,7 +357,12 @@ class RmtLedDriver : public DriverBase { // hot path. Grows only — keeps a big-enough existing allocation. void resizeSymbols() { if (!sourceBuffer_ || !correction_) return; - const nrOfLightsType n = sourceBuffer_->count(); + // Size for this driver's window slice, not the whole source buffer — an + // onboard-LED slice of 1 reserves 1 light's worth of symbols, not the full + // grid's. Derive the window length directly (windowSlice is independent of + // the pin parse, so the buffer sizes correctly even before pins are set). + nrOfLightsType winStart, n; + windowSlice(sourceBuffer_->count(), winStart, n); const uint8_t ch = correction_->outChannels; if (n == 0 || ch == 0) return; const size_t need = symbolsFor(n, ch); diff --git a/src/main.cpp b/src/main.cpp index 8a3ecd33..23483ea3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -58,6 +58,7 @@ #include "core/HttpServerModule.h" #include "core/SystemModule.h" #include "core/AudioModule.h" +#include "core/I2cScanModule.h" #include "core/FirmwareUpdateModule.h" #include "core/ImprovProvisioningModule.h" #include "core/DevicesModule.h" @@ -126,6 +127,7 @@ static void registerModuleTypes() { mm::ModuleFactory::registerType("HttpServerModule", "core/HttpServerModule.md"); mm::ModuleFactory::registerType("SystemModule", "core/SystemModule.md"); mm::ModuleFactory::registerType("AudioModule", "core/AudioModule.md"); + mm::ModuleFactory::registerType("I2cScanModule", "core/I2cScanModule.md"); mm::ModuleFactory::registerType("FirmwareUpdateModule", "core/FirmwareUpdateModule.md"); mm::ModuleFactory::registerType("ImprovProvisioningModule", "core/ImprovProvisioningModule.md"); mm::ModuleFactory::registerType("DevicesModule", "core/DevicesModule.md"); diff --git a/src/platform/desktop/platform_config.h b/src/platform/desktop/platform_config.h index 3baab2d9..f1327927 100644 --- a/src/platform/desktop/platform_config.h +++ b/src/platform/desktop/platform_config.h @@ -27,6 +27,15 @@ constexpr uint8_t parlioLanes = 0; // band math runs end-to-end in host tests; only live capture is absent. constexpr bool hasI2sMic = false; +// Audio-codec config type — desktop has no codec (audioCodecInit stubs to true), +// but platform.h declares audioCodecInit(CodecType, const AudioCodecPins&, …) for +// every platform, so the types must exist here too. Mirror the esp32 definitions; +// desktop is always CodecType::None. +enum class CodecType : uint8_t { None = 0, Es8311 = 1 }; +struct AudioCodecPins { uint16_t i2cSda; uint16_t i2cScl; uint16_t mclk; uint8_t i2cAddr; }; +constexpr CodecType audioCodecType = CodecType::None; +constexpr AudioCodecPins audioCodecPins = { 0, 0, 0, 0 }; + // Desktop is not a target of the Ethernet-only firmware profile; it ships // WiFi stubs and exercises the hasWiFi==true code path for compile coverage. constexpr bool hasWiFi = true; diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index a0991145..80b85783 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -56,10 +56,6 @@ inline int make_nonblocking(int fd) { u_long mode = 1; return ::ioctlsocket(sock(fd), FIONBIO, &mode); } -inline int make_blocking(int fd) { - u_long mode = 0; - return ::ioctlsocket(sock(fd), FIONBIO, &mode); -} #else inline int sock(int fd) { return fd; } inline int close_sock(int fd) { return ::close(fd); } @@ -71,10 +67,6 @@ inline int make_nonblocking(int fd) { int flags = ::fcntl(fd, F_GETFL, 0); return ::fcntl(fd, F_SETFL, flags | O_NONBLOCK); } -inline int make_blocking(int fd) { - int flags = ::fcntl(fd, F_GETFL, 0); - return ::fcntl(fd, F_SETFL, flags & ~O_NONBLOCK); -} #endif #ifdef _WIN32 // Winsock 2.2 must be initialized once per process before any socket call. @@ -468,10 +460,10 @@ bool wifiSetTxPower(int8_t quarterDbm) { return quarterDbm == 0; } bool mdnsInit(const char* /*deviceName*/) { return false; } void mdnsStop() {} void mdnsShutdown() {} -// No mDNS on desktop — browse is a no-op (no hosts found). A PC instance discovers peers -// via the HTTP sweep instead (see DevicesModule). -bool mdnsBrowse(const char* /*service*/, const char* /*proto*/, uint32_t /*timeoutMs*/, - MdnsHostCb /*cb*/, void* /*user*/) { return false; } +// mDNS advertise is a device-only concern, so these are host stubs. Discovery is UDP +// presence (DevicesModule + WledPacket) over UdpSocket, which runs on desktop too — so the +// discovery path is unit-testable on the host with real loopback datagrams (a bound socket +// or DevicesModule::injectPacketForTest). // OTA — no-op on desktop (no OTA partition). The /api/firmware/url route // guards with `if constexpr (mm::platform::hasOta)` and returns 501 here, @@ -487,135 +479,6 @@ bool http_fetch_to_ota(const char* /*url*/, return false; } -// Short-timeout outbound HTTP GET for device discovery (DevicesModule's scan). -// Real implementation on desktop (a PC projectMM instance must scan its LAN too): -// a plain blocking socket with a receive/send timeout — no TLS, no libcurl, LAN -// HTTP only. Parses http://host[:port]/path, returns the HTTP status code (0 on -// any failure) and fills `body` with the response body (NUL-terminated, truncated). -int httpGet(const char* url, uint32_t timeoutMs, char* body, size_t bodyLen) { - if (body && bodyLen) body[0] = '\0'; - if (!url) return 0; - - // Parse "http://host[:port]/path". Only plain http:// (LAN devices). - constexpr const char* kPrefix = "http://"; - const size_t prefixLen = std::strlen(kPrefix); - if (std::strncmp(url, kPrefix, prefixLen) != 0) return 0; - const char* hostStart = url + prefixLen; - const char* slash = std::strchr(hostStart, '/'); - const char* path = slash ? slash : "/"; - char host[64] = {}; - uint16_t port = 80; - { - const char* hostEnd = slash ? slash : (hostStart + std::strlen(hostStart)); - const char* colon = static_cast( - std::memchr(hostStart, ':', static_cast(hostEnd - hostStart))); - const char* nameEnd = colon ? colon : hostEnd; - size_t hlen = static_cast(nameEnd - hostStart); - if (hlen == 0 || hlen >= sizeof(host)) return 0; - std::memcpy(host, hostStart, hlen); - host[hlen] = '\0'; - if (colon) { - int p = std::atoi(colon + 1); - if (p > 0 && p <= 65535) port = static_cast(p); - } - } - - int fd = open_sock(AF_INET, SOCK_STREAM, 0); - if (fd < 0) return 0; - - sockaddr_in addr{}; - addr.sin_family = AF_INET; - addr.sin_port = htons(port); - // Discovery probes a numeric IP (the scan walks the subnet), so inet_pton is - // enough — no DNS resolution needed for the LAN sweep. - if (inet_pton(AF_INET, host, &addr.sin_addr) != 1) { close_sock(fd); return 0; } - - // NON-BLOCKING connect bounded by select() — this is the crux. SO_RCVTIMEO / - // SO_SNDTIMEO do NOT bound connect() (a POSIX gotcha): a blocking connect() to a - // dead/firewalled IP hangs for the OS default (~75 s on macOS), which would - // freeze the single-threaded desktop loop on the first unreachable host in the - // sweep. So: make the socket non-blocking, start the connect, select() on - // writability with our short timeout, and check SO_ERROR. Then restore blocking - // mode for the recv/send (those ARE bounded by SO_*TIMEO). - make_nonblocking(fd); - int crc = ::connect(sock(fd), reinterpret_cast(&addr), sizeof(addr)); -#ifdef _WIN32 - const bool inProgress = (crc != 0) && (::WSAGetLastError() == WSAEWOULDBLOCK); -#else - const bool inProgress = (crc != 0) && (errno == EINPROGRESS); -#endif - if (crc != 0 && !inProgress) { close_sock(fd); return 0; } - if (inProgress) { - fd_set wset; - FD_ZERO(&wset); - FD_SET(sock(fd), &wset); - timeval ctv{}; - ctv.tv_sec = static_cast(timeoutMs / 1000); - ctv.tv_usec = static_cast((timeoutMs % 1000) * 1000); - int sel = ::select(static_cast(sock(fd)) + 1, nullptr, &wset, nullptr, &ctv); - if (sel <= 0) { close_sock(fd); return 0; } // timeout (0) or error (<0) - int soErr = 0; - socklen_t errLen = sizeof(soErr); - ::getsockopt(sock(fd), SOL_SOCKET, SO_ERROR, - reinterpret_cast(&soErr), &errLen); - if (soErr != 0) { close_sock(fd); return 0; } // connect failed (refused/unreachable) - } - // Connected. Back to blocking, with recv/send bounded by SO_*TIMEO. - make_blocking(fd); -#ifdef _WIN32 - DWORD tv = timeoutMs; -#else - timeval tv{}; - tv.tv_sec = static_cast(timeoutMs / 1000); - tv.tv_usec = static_cast((timeoutMs % 1000) * 1000); -#endif - ::setsockopt(sock(fd), SOL_SOCKET, SO_RCVTIMEO, - reinterpret_cast(&tv), sizeof(tv)); - ::setsockopt(sock(fd), SOL_SOCKET, SO_SNDTIMEO, - reinterpret_cast(&tv), sizeof(tv)); - - char req[256]; - int reqLen = std::snprintf(req, sizeof(req), - "GET %s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\n\r\n", path, host); - // snprintf returns the length it WOULD have written: >= sizeof(req) means the - // request was truncated (an over-long path/host). Reject rather than send a - // truncated header or read past the buffer with the inflated length. - if (reqLen <= 0 || static_cast(reqLen) >= sizeof(req) || - ::send(sock(fd), req, reqLen, 0) != reqLen) { - close_sock(fd); - return 0; - } - - // Read the whole (small) response into a local buffer, then split headers/body. - char resp[2048]; - int total = 0; - while (total < static_cast(sizeof(resp)) - 1) { - int n = ::recv(sock(fd), resp + total, static_cast(sizeof(resp)) - 1 - total, 0); - if (n <= 0) break; - total += n; - } - close_sock(fd); - resp[total] = '\0'; - if (total == 0) return 0; - - // Status line: "HTTP/1.x ...". Parse the code. - int status = 0; - const char* sp = std::strchr(resp, ' '); - if (sp) status = std::atoi(sp + 1); - - // Body starts after the blank line (CRLFCRLF). - if (body && bodyLen > 1) { - const char* bodyStart = std::strstr(resp, "\r\n\r\n"); - if (bodyStart) { - bodyStart += 4; - size_t blen = std::strlen(bodyStart); - if (blen > bodyLen - 1) blen = bodyLen - 1; - std::memcpy(body, bodyStart, blen); - body[blen] = '\0'; - } - } - return status; -} // Improv WiFi — no USB-serial path on desktop. The module gates with // `if constexpr (mm::platform::hasImprov)` and never calls this on desktop; @@ -764,23 +627,15 @@ bool TcpConnection::write(const uint8_t* data, size_t len) { int TcpConnection::writeSome(const uint8_t* data, size_t len) { if (fd_ < 0) return -1; if (len == 0) return 0; - // The client socket is blocking (SO_RCVTIMEO drives recv's read timeout), so a plain - // ::send() would block when the kernel send buffer is full. Toggle non-blocking around the - // send to keep this truly non-blocking, then restore so recv's timeout semantics hold. - // Evaluate the would-block / EINTR status BEFORE make_blocking, since that ioctl/fcntl - // can clobber the error state. - make_nonblocking(fd_); + // The accept()ed socket is persistently non-blocking (set in TcpServer::accept), so a + // plain ::send() never blocks — no toggle needed. A full kernel send buffer surfaces as + // EWOULDBLOCK, which we report as 0 ("try later"); the caller advances its own offset. auto n = ::send(sock(fd_), reinterpret_cast(data), static_cast(len), 0); - bool wouldBlock = (n < 0) && sockWouldBlock(); -#ifndef _WIN32 - bool interrupted = (n < 0) && (errno == EINTR); -#endif - make_blocking(fd_); if (n > 0) return static_cast(n); if (n == 0) return 0; - if (wouldBlock) return 0; // buffer full — try later + if (sockWouldBlock()) return 0; // buffer full — try later #ifndef _WIN32 - if (interrupted) return 0; // interrupted — try later + if (errno == EINTR) return 0; // interrupted — try later #endif return -1; // real socket error } @@ -836,19 +691,20 @@ TcpConnection TcpServer::accept() { SOCKET client = ::accept(sock(fd_), nullptr, nullptr); if (client == INVALID_SOCKET) return TcpConnection(); int clientFd = static_cast(client); - // Match POSIX: socket stays blocking, SO_RCVTIMEO gives recv a 2-second - // timeout. Windows SO_RCVTIMEO takes a DWORD millisecond count (not a - // timeval). writeSome() toggles non-blocking around its send call to - // emulate POSIX's MSG_DONTWAIT. - DWORD timeoutMs = 2000; - ::setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, - reinterpret_cast(&timeoutMs), sizeof(timeoutMs)); + // NON-BLOCKING (see the POSIX branch below for the full rationale): a blocking recv on + // the single-loop server stalls the whole render loop. make_nonblocking → recv returns + // WSAEWOULDBLOCK → read() reports -1 ("nothing yet") immediately, never blocking. + make_nonblocking(clientFd); #else int clientFd = ::accept(fd_, nullptr, nullptr); if (clientFd < 0) return TcpConnection(); - // Set read timeout (2 seconds) instead of non-blocking - struct timeval tv = {2, 0}; - setsockopt(clientFd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + // NON-BLOCKING client socket. The HTTP server is serviced from the single render loop, + // so a blocking recv()'s timeout (we used 2 s) froze the WHOLE loop whenever a request's + // bytes hadn't landed the instant accept() returned — UI to a crawl. Non-blocking makes + // read() return -1 ("nothing yet") immediately, so the loop never stalls; the request + // (which lands within ~1 ms on localhost/LAN) is read across a few rapid retries in + // handleConnection. recv returns EWOULDBLOCK → -1, matching read()'s contract. + make_nonblocking(clientFd); #endif return TcpConnection(clientFd); } @@ -930,10 +786,17 @@ RmtLoopbackResult parlioWs2812Loopback(const uint16_t* /*dataPins*/, uint8_t /*l return {}; // not supported off the P4 } +// Audio codec — desktop has no codec, so init is a no-op that succeeds (there's +// nothing to bring up); the inert audioMicInit below is what keeps capture off. +bool audioCodecInit(CodecType /*type*/, const AudioCodecPins& /*pins*/, uint32_t /*sampleRate*/) { + return true; +} +void audioCodecDeinit() {} + // I2S microphone — no capture on desktop (hasI2sMic == false, AudioModule inert), // so init fails and read returns nothing. bool audioMicInit(AudioMicHandle& /*h*/, uint16_t /*wsPin*/, uint16_t /*sdPin*/, - uint16_t /*sckPin*/, uint32_t /*sampleRate*/) { + uint16_t /*sckPin*/, int16_t /*mclkPin*/, uint32_t /*sampleRate*/) { return false; } size_t audioMicRead(AudioMicHandle& /*h*/, int32_t* /*out*/, size_t /*maxSamples*/) { @@ -959,4 +822,11 @@ void audioFft(const float* windowed, size_t n, float* outMag) { } } +// No I2C bus on the desktop host — report it as unavailable (the sentinel), the same as +// an I2C-less ESP32 target, so the module shows "bus unavailable" rather than a misleading +// "0 devices found" (which means "scanned a real bus, nothing ACKed"). +size_t i2cScan(uint16_t /*sda*/, uint16_t /*scl*/, uint8_t* /*out*/, size_t /*maxOut*/) { + return kI2cBusUnavailable; +} + } // namespace mm::platform diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h index c8c84411..5a460566 100644 --- a/src/platform/esp32/platform_config.h +++ b/src/platform/esp32/platform_config.h @@ -98,6 +98,33 @@ constexpr bool hasI2sMic = true; constexpr bool hasI2sMic = false; #endif + +// Some boards put the mic behind an I2S audio codec configured over I2C (vs a +// direct I2S MEMS mic). The codec type + its control pins are a fixed board +// property, so they live here per-target (like ethConfigDefault), not as +// AudioModule controls — the I2S data pins (ws/sd/sck) stay user controls. +// `audioCodecInit` (platform.h) consumes these; CodecType is neutral so a second +// codec is just another enum value + a backend branch. +enum class CodecType : uint8_t { None = 0, Es8311 = 1 }; +struct AudioCodecPins { + uint16_t i2cSda; + uint16_t i2cScl; + uint16_t mclk; // I2S master clock the codec needs (separate from BCLK/WS) + uint8_t i2cAddr; // codec I2C address (ES8311 default 0x18) +}; + +// Default None; the ESP32-S31 Function-CoreBoard has an ES8311 (addr 0x18, I2C +// SDA on GPIO51 / SCL on GPIO50, MCLK on GPIO52 — bench-confirmed by I2C scan; the +// schematic net labels read SDA/SCL the other way round. See +// docs/reference/esp32-s31-coreboard.md.). +#ifdef CONFIG_IDF_TARGET_ESP32S31 +constexpr CodecType audioCodecType = CodecType::Es8311; +constexpr AudioCodecPins audioCodecPins = { /*sda*/ 51, /*scl*/ 50, /*mclk*/ 52, /*addr*/ 0x18 }; +#else +constexpr CodecType audioCodecType = CodecType::None; +constexpr AudioCodecPins audioCodecPins = { 0, 0, 0, 0 }; +#endif + // WiFi is compiled out in the Ethernet-only build profile. ESP-IDF v6.x has no // CONFIG_ESP_WIFI_ENABLED switch, so the eth-only build instead drops the WiFi // components via EXCLUDE_COMPONENTS and defines MM_NO_WIFI (see esp32/main/CMakeLists.txt). diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp index 3bbd40f4..c2cea406 100644 --- a/src/platform/esp32/platform_esp32.cpp +++ b/src/platform/esp32/platform_esp32.cpp @@ -998,10 +998,11 @@ bool wifiSetTxPower(int8_t quarterDbm) { return quarterDbm == 0; } #endif // MM_NO_WIFI // Bring the mDNS stack up (idempotent) and ADVERTISE this device as .local. -// Advertising is gated by the user's mDNS toggle; the stack init is NOT — browse needs -// the stack regardless (see mdnsBrowseStart), so mdns_init stays even when the toggle is -// off. mdns_init is safe to call when already running (returns an already-init error we -// treat as fine). +// Advertising is gated by the user's mDNS toggle; the stack init stays — mdnsStop() +// removes the services + hostname but keeps the stack up, so toggling mDNS back on +// re-advertises without a full re-init. mdns_init is safe to call when already running +// (returns an already-init error we treat as fine). mDNS here is advertise-only; peer +// discovery is UDP presence (see DevicesModule + WledPacket). static bool mdnsStackUp_ = false; static bool ensureMdnsStack() { @@ -1018,107 +1019,94 @@ static bool ensureMdnsStack() { bool mdnsInit(const char* deviceName) { if (!ensureMdnsStack()) return false; esp_err_t err = mdns_hostname_set(deviceName); + ESP_LOGI(NET_TAG, "mDNS hostname set %s: %s", deviceName, esp_err_to_name(err)); if (err != ESP_OK) { ESP_LOGE(NET_TAG, "mDNS hostname set failed: %s", esp_err_to_name(err)); return false; } - // Advertise an `_http._tcp` service so other devices DISCOVER us by browsing the - // service type (not just by resolving our hostname) — the standard, push-style way - // a web device announces itself (WLED, ESPHome, Hue all advertise `_http._tcp`). - // This is what lets two projectMM devices find each other over mDNS with no subnet - // sweep. The instance name is the deviceName; the port is the HTTP server's (80). - // Branch on whether the service already exists (a reconnect re-runs this) rather - // than treating ANY mdns_service_add failure as "already there" — an add failure - // could be OOM/invalid-state, which must surface, not be silently logged as started. - esp_err_t svcErr = mdns_service_exists("_http", "_tcp", nullptr) - ? mdns_service_instance_name_set("_http", "_tcp", deviceName) - : mdns_service_add(deviceName, "_http", "_tcp", 80, nullptr, 0); - if (svcErr != ESP_OK) { - ESP_LOGE(NET_TAG, "mDNS _http._tcp advertise failed: %s", esp_err_to_name(svcErr)); - return false; // hostname is set, but advertising failed — report it + + // 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 + // current netif/IP — a remove+add does, by driving the service back through the IDF + // probe→announce state machine on the active interface. The remove is a no-op + // (ESP_OK) when the service isn't present (first run), so this one path serves both + // first-advertise and re-advertise. Logged per step so a bench can compare boards. + const bool reAdvertise = mdns_service_exists("_http", "_tcp", nullptr); + mdns_service_remove("_http", "_tcp"); + mdns_service_remove("_wled", "_tcp"); + + // `_http._tcp`: how other devices DISCOVER us by browsing the service type (the + // standard push-style announce — WLED/ESPHome/Hue all advertise `_http._tcp`). Fatal + // if it fails: discovery is the point. Instance name = deviceName, port = HTTP (80). + esp_err_t httpErr = mdns_service_add(deviceName, "_http", "_tcp", 80, nullptr, 0); + ESP_LOGI(NET_TAG, "mDNS _http._tcp add (%s): %s", + reAdvertise ? "re-advertise" : "fresh", esp_err_to_name(httpErr)); + if (httpErr != ESP_OK) { + ESP_LOGE(NET_TAG, "mDNS _http._tcp advertise failed: %s", esp_err_to_name(httpErr)); + return false; } - // Tag the service with a `mm=1` TXT record so a browsing projectMM peer can tell us - // apart from a generic `_http._tcp` web box (WLED/ESPHome/Hue all share that service) - // WITHOUT an HTTP probe — DevicesModule reads this to classify the peer as projectMM - // straight from the browse. Idempotent: set on both the add and reconnect paths. A - // TXT failure is non-fatal (advertising still works; the peer just falls back to the - // HTTP scan to classify us), so it's logged, not returned. + // `mm=1` TXT so a browsing projectMM peer tells us apart from a generic `_http._tcp` + // box without an HTTP probe — DevicesModule classifies us projectMM straight from the + // announcement. Non-fatal (advertising still works without it). esp_err_t txtErr = mdns_service_txt_item_set("_http", "_tcp", "mm", "1"); - if (txtErr != ESP_OK) - ESP_LOGW(NET_TAG, "mDNS _http._tcp TXT mm=1 set failed: %s", esp_err_to_name(txtErr)); - ESP_LOGI(NET_TAG, "mDNS started: %s.local (advertising _http._tcp:80, mm=1)", deviceName); + ESP_LOGI(NET_TAG, "mDNS _http._tcp TXT mm=1 set: %s", esp_err_to_name(txtErr)); + + // `_wled._tcp`: the service the native WLED apps + Home Assistant browse for — how a + // projectMM device appears in the WLED ecosystem without speaking WLED's UDP protocol + // (the HTTP server on :80 answers their /json/info probe). Non-fatal: a failure just + // means we don't show in those apps; the rest of discovery still works. + esp_err_t wledErr = mdns_service_add(deviceName, "_wled", "_tcp", 80, nullptr, 0); + ESP_LOGI(NET_TAG, "mDNS _wled._tcp add: %s", esp_err_to_name(wledErr)); + // `mac=` TXT — a real WLED carries `mac=<12 hex>` on its _wled._tcp record, and the + // native apps key the discovered device on it (without it the record is discarded, so + // the device never lists). Lowercase hex, no separators, matching WLED's format. + uint8_t mac[6] = {}; + esp_efuse_mac_get_default(mac); + char macStr[13]; + std::snprintf(macStr, sizeof(macStr), "%02x%02x%02x%02x%02x%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + esp_err_t wledTxtErr = mdns_service_txt_item_set("_wled", "_tcp", "mac", macStr); + ESP_LOGI(NET_TAG, "mDNS _wled._tcp TXT mac=%s set: %s", macStr, esp_err_to_name(wledTxtErr)); + + // Summary reflects the ACTUAL per-step results (each logged above): _http._tcp is up + // (we returned early on its failure), the TXT / _wled additions are non-fatal so report + // ok/fail rather than claiming success unconditionally. + ESP_LOGI(NET_TAG, "mDNS started: %s.local (_http._tcp:80 mm=1:%s, _wled._tcp:80:%s mac=%s:%s)", + deviceName, + txtErr == ESP_OK ? "ok" : "fail", + wledErr == ESP_OK ? "ok" : "fail", + macStr, + wledTxtErr == ESP_OK ? "ok" : "fail"); return true; } void mdnsStop() { - // Tearing the stack down would also kill browse. The toggle-off path wants to stop - // ADVERTISING, not lose discovery — so keep the stack but drop both the advertised - // hostname AND the _http._tcp service record; full mdns_free only on teardown - // (where everything stops anyway). mdns_service_remove is a no-op if not added. + // Stop ADVERTISING but keep the stack up (a re-init then re-advertises cheaply); full + // mdns_free is teardown's job. Drop BOTH advertised services AND the hostname, matching + // mdnsInit which adds both _http._tcp and _wled._tcp: a network drop / interface switch + // (NetworkModule calls this on eth/WiFi drop + switch) must remove BOTH, or a stale + // _wled._tcp survives the churn and confuses a later re-advertise. mdns_service_remove + // is a no-op (ESP_OK) when the service isn't present. if (mdnsStackUp_) { - mdns_service_remove("_http", "_tcp"); + esp_err_t httpRm = mdns_service_remove("_http", "_tcp"); + esp_err_t wledRm = mdns_service_remove("_wled", "_tcp"); mdns_hostname_set(""); + ESP_LOGI(NET_TAG, "mDNS stopped advertising (_http remove: %s, _wled remove: %s)", + esp_err_to_name(httpRm), esp_err_to_name(wledRm)); } } -// Full stack teardown — only on module teardown, where browse stops too. +// Full stack teardown (mdns_free) — only at module teardown. void mdnsShutdown() { if (mdnsStackUp_) { mdns_free(); mdnsStackUp_ = false; } } -// --- mDNS service browse (synchronous, bounded) --- - -// One synchronous PTR browse for `service`/`proto`, blocking up to `timeoutMs`, then it -// frees everything it allocated before returning. Self-contained ON PURPOSE: the earlier -// async API (mdns_query_async_new + poll-the-handle-across-ticks) raced the mDNS task's -// own search-expiry timer — when a query's window lapsed, the component freed the search's -// internal queue, and our next-tick poll asserted on it (xQueueSemaphoreTake on a null -// queue, crashing on a UI refresh). Holding no handle across ticks closes that window by -// construction. The cost is a bounded blocking call: DevicesModule calls this on loop1s -// (not the render hot path) for ONE service type per tick with a small timeout, the -// standard mDNS-query pattern (WLED/ESPHome do the same), so the tick budget is fine. -bool mdnsBrowse(const char* service, const char* proto, uint32_t timeoutMs, - MdnsHostCb cb, void* user) { - // Browse needs only the mDNS stack, not advertising — bring it up regardless of the - // advertise toggle (mdnsStop clears the hostname but keeps the stack), so a device - // that doesn't advertise can still discover others. - if (!ensureMdnsStack()) return false; - mdns_result_t* results = nullptr; - if (mdns_query_ptr(service, proto, timeoutMs, 16, &results) != ESP_OK) return false; - for (mdns_result_t* r = results; r && cb; r = r->next) { - MdnsHost h{}; - // A PTR/service browse gives the friendly service *instance* name in - // `instance_name` (what we advertise — the deviceName, e.g. "Bench-P4") and the - // lower-level host record in `hostname`. Prefer the instance name so a peer shows - // the device's name, not its `.local` host; fall back to hostname if absent. - const char* name = (r->instance_name && r->instance_name[0]) ? r->instance_name - : (r->hostname ? r->hostname : nullptr); - if (name) std::snprintf(h.hostname, sizeof(h.hostname), "%s", name); - h.port = r->port; - // Scan the service's TXT records for our `mm=1` marker — a projectMM device tags - // its _http._tcp advertisement with it (see mdnsInit), so a peer browsing the - // generic _http._tcp service can classify us without an HTTP probe. - for (size_t i = 0; i < r->txt_count; i++) { - if (r->txt[i].key && std::strcmp(r->txt[i].key, "mm") == 0 - && r->txt_value_len[i] == 1 && r->txt[i].value && r->txt[i].value[0] == '1') { - h.isProjectMM = true; - break; - } - } - // First IPv4 address in the result's addr list. - for (mdns_ip_addr_t* a = r->addr; a; a = a->next) { - if (a->addr.type == ESP_IPADDR_TYPE_V4) { - uint32_t v = a->addr.u_addr.ip4.addr; // little-endian packed (octet i = byte i) - h.ip[0] = v & 0xff; h.ip[1] = (v >> 8) & 0xff; - h.ip[2] = (v >> 16) & 0xff; h.ip[3] = (v >> 24) & 0xff; - break; - } - } - cb(h, user); - } - if (results) mdns_query_results_free(results); - return true; -} +// mDNS is advertise-only (mdnsInit). Discovery is UDP presence (DevicesModule + WledPacket): +// a projectMM device broadcasts and listens for the 44-byte presence packet on UDP 65506. +// Keeping discovery off mDNS also keeps the advertise stable, because a PTR query for a +// service this device +// also hosts destabilises our own advertise — see docs/history/decisions.md. // UdpSocket diff --git a/src/platform/esp32/platform_esp32_es8311.cpp b/src/platform/esp32/platform_esp32_es8311.cpp new file mode 100644 index 00000000..fe840626 --- /dev/null +++ b/src/platform/esp32/platform_esp32_es8311.cpp @@ -0,0 +1,150 @@ +// ES8311 audio-codec init — the I2C control half of the microphone path on boards +// whose mic is an analog part behind an ES8311 I2S codec (the ESP32-S31 +// Function-CoreBoard), rather than a direct digital I2S MEMS mic. The I2S *read* +// stays in platform_esp32_i2s.cpp (audioMic*); this file only brings the codec up +// over I2C so it streams its ADC (mic) onto the I2S bus the read then drains. So +// the audio domain code (AudioModule) is unchanged — it calls audioCodecInit (a +// no-op on direct-mic boards) before audioMicInit, and reads samples as always. +// +// Uses Espressif's esp_codec_dev managed component (the recognised ES8311 driver), +// gated to the S31 in main/idf_component.yml. This is the platform layer's first +// I2C master bus — owned here, behind the boundary. +// +// Compiles on every ESP32 chip: the codec path is under SOC_I2S_SUPPORTED and the +// esp_codec_dev availability gate; everything else gets an inert stub (audioCodecInit +// returns true — nothing to bring up — so the uniform AudioModule call works). + +#include "platform/platform.h" + +#include "sdkconfig.h" +#include "soc/soc_caps.h" + +// esp_codec_dev is only pulled on the S31 (idf_component.yml rule). Gate the codec +// implementation on its presence so the file still compiles on every other target. +#if SOC_I2S_SUPPORTED && __has_include("esp_codec_dev.h") +#define MM_HAS_ES8311 1 +#endif + +#if MM_HAS_ES8311 + +#include "driver/i2c_master.h" +#include "esp_codec_dev.h" +#include "esp_codec_dev_defaults.h" +#include "esp_log.h" + +#include // std::nothrow + +namespace mm::platform { + +namespace { + +const char* ES_TAG = "mm_es8311"; + +// The codec device + the interfaces and I2C bus it sits on, kept alive between +// init and deinit (the codec keeps streaming once opened; the I2S read drains it). +struct CodecState { + i2c_master_bus_handle_t i2cBus = nullptr; + const audio_codec_ctrl_if_t* ctrl = nullptr; + const audio_codec_if_t* codec = nullptr; + esp_codec_dev_handle_t dev = nullptr; +}; + +CodecState* g_codec = nullptr; + +// Tear down a partially- or fully-built CodecState in reverse order. +void deinitState(CodecState* st) { + if (!st) return; + if (st->dev) { esp_codec_dev_close(st->dev); esp_codec_dev_delete(st->dev); } + if (st->codec) audio_codec_delete_codec_if(st->codec); + if (st->ctrl) audio_codec_delete_ctrl_if(st->ctrl); + if (st->i2cBus) i2c_del_master_bus(st->i2cBus); + delete st; +} + +} // namespace + +bool audioCodecInit(CodecType type, const AudioCodecPins& pins, uint32_t sampleRate) { + if (type == CodecType::None) return true; // direct-mic board: nothing to do + if (type != CodecType::Es8311) return false; // unknown codec for this build + + audioCodecDeinit(); // idempotent: a re-init (pin/rate change) rebuilds cleanly + auto* st = new (std::nothrow) CodecState(); + if (!st) return false; + + // The platform's I2C master bus — owned by the codec (no other platform user yet). + i2c_master_bus_config_t busCfg = {}; + busCfg.i2c_port = I2C_NUM_0; + busCfg.sda_io_num = static_cast(pins.i2cSda); + busCfg.scl_io_num = static_cast(pins.i2cScl); + busCfg.clk_source = I2C_CLK_SRC_DEFAULT; + busCfg.glitch_ignore_cnt = 7; + busCfg.flags.enable_internal_pullup = true; + if (i2c_new_master_bus(&busCfg, &st->i2cBus) != ESP_OK) { + ESP_LOGE(ES_TAG, "i2c bus init failed (sda %u scl %u)", pins.i2cSda, pins.i2cScl); + delete st; + return false; + } + + // ES8311 over I2C — the codec's control interface (the I2S *data* interface is + // ours: audioMicInit owns the RX channel, so we don't hand esp_codec_dev the I2S + // handles or use esp_codec_dev_read; the codec just needs its registers set so it + // streams the ADC onto the bus). The mic path is record-only. + audio_codec_i2c_cfg_t i2cCtrlCfg = {}; + i2cCtrlCfg.port = I2C_NUM_0; + i2cCtrlCfg.addr = pins.i2cAddr; // ES8311 default 0x18 + i2cCtrlCfg.bus_handle = st->i2cBus; + st->ctrl = audio_codec_new_i2c_ctrl(&i2cCtrlCfg); + if (!st->ctrl) { ESP_LOGE(ES_TAG, "codec i2c ctrl failed"); deinitState(st); return false; } + + es8311_codec_cfg_t es8311Cfg = {}; + es8311Cfg.ctrl_if = st->ctrl; + es8311Cfg.codec_mode = ESP_CODEC_DEV_WORK_MODE_ADC; // record / mic only + es8311Cfg.use_mclk = true; // MCLK provided to the codec on GPIO52 + es8311Cfg.mclk_div = 256; // MCLK = 256 * sample_rate (the standard + // I2S ratio; the codec's coeff table is + // keyed on it — 0 fails "configure rate"). + es8311Cfg.pa_pin = -1; // mic path needs no power amp + st->codec = es8311_codec_new(&es8311Cfg); + if (!st->codec) { ESP_LOGE(ES_TAG, "es8311_codec_new failed"); deinitState(st); return false; } + + esp_codec_dev_cfg_t devCfg = {}; + devCfg.codec_if = st->codec; + devCfg.dev_type = ESP_CODEC_DEV_TYPE_IN; // input (mic) device + st->dev = esp_codec_dev_new(&devCfg); + if (!st->dev) { ESP_LOGE(ES_TAG, "esp_codec_dev_new failed"); deinitState(st); return false; } + + esp_codec_dev_sample_info_t fs = {}; + fs.sample_rate = sampleRate; + fs.channel = 1; + fs.bits_per_sample = 16; + if (esp_codec_dev_open(st->dev, &fs) != ESP_CODEC_DEV_OK) { + ESP_LOGE(ES_TAG, "esp_codec_dev_open failed"); + deinitState(st); + return false; + } + esp_codec_dev_set_in_gain(st->dev, 30.0f); // mic gain (dB), a reasonable default + + g_codec = st; + return true; +} + +void audioCodecDeinit() { + if (g_codec) { deinitState(g_codec); g_codec = nullptr; } +} + +} // namespace mm::platform + +#else // !MM_HAS_ES8311 — no codec on this target: inert stub. + +#include + +namespace mm::platform { +bool audioCodecInit(CodecType type, const AudioCodecPins&, uint32_t) { + // No codec: there's nothing to configure, so a None request succeeds and any + // codec request fails (a board asking for a codec this build can't drive). + return type == CodecType::None; +} +void audioCodecDeinit() {} +} // namespace mm::platform + +#endif // MM_HAS_ES8311 diff --git a/src/platform/esp32/platform_esp32_httpget.cpp b/src/platform/esp32/platform_esp32_httpget.cpp deleted file mode 100644 index 5f85ab8f..00000000 --- a/src/platform/esp32/platform_esp32_httpget.cpp +++ /dev/null @@ -1,55 +0,0 @@ -// Short-timeout outbound HTTP GET for device discovery (DevicesModule's scan). -// Distinct from platform_esp32_ota.cpp's http_fetch_to_ota: this is a synchronous -// fetch-into-a-small-buffer for LAN device probing — plain HTTP (no TLS / cert -// bundle, devices are local), a short per-request timeout so a dead IP doesn't -// stall the sweep, and a status-code return so the caller can identify the device. -// Kept in its own file (same split rationale as the OTA cut) so the probe doesn't -// pull the OTA / TLS machinery into a build that only wants discovery. - -#include "platform/platform.h" - -#include "esp_http_client.h" -#include "esp_log.h" - -#include - -namespace mm::platform { - -int httpGet(const char* url, uint32_t timeoutMs, char* body, size_t bodyLen) { - if (body && bodyLen) body[0] = '\0'; - if (!url || !url[0]) return 0; - - esp_http_client_config_t cfg = {}; - cfg.url = url; - cfg.timeout_ms = static_cast(timeoutMs); - cfg.method = HTTP_METHOD_GET; - // LAN devices, plain HTTP — no redirect-following needed (a probe target - // either answers its own API or it doesn't); keep it minimal. - cfg.disable_auto_redirect = true; - - esp_http_client_handle_t client = esp_http_client_init(&cfg); - if (!client) return 0; - - int status = 0; - // open(0) sends the request headers with no body; then read the response. - if (esp_http_client_open(client, 0) == ESP_OK) { - // Must fetch headers before status/length are valid. - esp_http_client_fetch_headers(client); - status = esp_http_client_get_status_code(client); - if (body && bodyLen > 1) { - int total = 0; - const int cap = static_cast(bodyLen) - 1; // leave room for NUL - while (total < cap) { - int n = esp_http_client_read(client, body + total, cap - total); - if (n <= 0) break; // 0 = done, <0 = error/closed - total += n; - } - body[total] = '\0'; - } - } - esp_http_client_close(client); - esp_http_client_cleanup(client); - return status; -} - -} // namespace mm::platform diff --git a/src/platform/esp32/platform_esp32_i2c.cpp b/src/platform/esp32/platform_esp32_i2c.cpp new file mode 100644 index 00000000..9e9fae52 --- /dev/null +++ b/src/platform/esp32/platform_esp32_i2c.cpp @@ -0,0 +1,76 @@ +// I2C bus diagnostics — the platform::i2cScan seam (declared in platform.h). +// Domain-neutral: probes any I2C bus and reports which addresses ACK, the +// standard `i2cdetect` operation. The I2cScanModule (src/core/I2cScanModule.h) +// surfaces it; any I2C bring-up (a codec, a sensor) uses it to confirm wiring. +// +// Self-contained: opens a temporary master bus on the given pins, probes every +// 7-bit address, tears the bus down. It does not hold a bus another driver owns +// (the ES8311 codec owns its own bus in platform_esp32_es8311.cpp) — the scan +// is a momentary diagnostic, so it allocates and frees its bus per call. +// +// Gated on SOC_I2C_SUPPORTED with an inert stub otherwise, so any I2C-less +// target links (the module then reports "no I2C on this target"). + +#include "platform/platform.h" + +#include "soc/soc_caps.h" + +#include +#include + +#if SOC_I2C_SUPPORTED + +#include "driver/i2c_master.h" +#include "esp_log.h" + +namespace mm::platform { + +namespace { +const char* I2C_TAG = "mm_i2c"; +} // namespace + +size_t i2cScan(uint16_t sda, uint16_t scl, uint8_t* out, size_t maxOut) { + if (!out || maxOut == 0) return 0; + + i2c_master_bus_config_t busCfg = {}; + busCfg.i2c_port = I2C_NUM_0; + busCfg.sda_io_num = static_cast(sda); + busCfg.scl_io_num = static_cast(scl); + busCfg.clk_source = I2C_CLK_SRC_DEFAULT; + busCfg.glitch_ignore_cnt = 7; + busCfg.flags.enable_internal_pullup = true; + + // A failure here is most often "port already in use" — another driver (the + // ES8311 codec on I2C_NUM_0) currently holds the bus. Report that distinctly + // so the UI shows "bus in use", not a misleading "0 devices found". + i2c_master_bus_handle_t bus = nullptr; + if (i2c_new_master_bus(&busCfg, &bus) != ESP_OK) { + ESP_LOGW(I2C_TAG, "i2c bus unavailable (sda %u scl %u) — already in use?", sda, scl); + return kI2cBusUnavailable; + } + + // Probe the 7-bit address range (0x01–0x77; 0x00 and 0x78+ are reserved). + // A 50 ms per-address timeout is ample on a quiet bus and keeps a full scan + // well under a second — this runs from a UI button, off the render path. + size_t found = 0; + for (uint8_t addr = 0x01; addr < 0x78 && found < maxOut; addr++) { + if (i2c_master_probe(bus, addr, 50) == ESP_OK) out[found++] = addr; + } + + i2c_del_master_bus(bus); + return found; +} + +} // namespace mm::platform + +#else // !SOC_I2C_SUPPORTED — inert stub so an I2C-less target links + +namespace mm::platform { + +// No I2C peripheral on this target — report the bus as unavailable, distinct from a +// successful scan that found nothing (the module shows "bus in use / unavailable"). +size_t i2cScan(uint16_t, uint16_t, uint8_t*, size_t) { return kI2cBusUnavailable; } + +} // namespace mm::platform + +#endif // SOC_I2C_SUPPORTED diff --git a/src/platform/esp32/platform_esp32_i2s.cpp b/src/platform/esp32/platform_esp32_i2s.cpp index 29fafd67..3f9f593f 100644 --- a/src/platform/esp32/platform_esp32_i2s.cpp +++ b/src/platform/esp32/platform_esp32_i2s.cpp @@ -60,7 +60,7 @@ bool ensureFftInit() { } // namespace bool audioMicInit(AudioMicHandle& h, uint16_t wsPin, uint16_t sdPin, - uint16_t sckPin, uint32_t sampleRate) { + uint16_t sckPin, int16_t mclkPin, uint32_t sampleRate) { auto* st = new (std::nothrow) MicState(); if (!st) return false; @@ -82,7 +82,9 @@ bool audioMicInit(AudioMicHandle& h, uint16_t wsPin, uint16_t sdPin, .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(sampleRate), .slot_cfg = slotCfg, .gpio_cfg = { - .mclk = I2S_GPIO_UNUSED, // self-clocked, no master clock + // MCLK: unused for a self-clocked MEMS mic (INMP441); driven on the + // given pin for a codec that needs a master clock (the ES8311). −1 = none. + .mclk = mclkPin < 0 ? I2S_GPIO_UNUSED : static_cast(mclkPin), .bclk = static_cast(sckPin), .ws = static_cast(wsPin), .dout = I2S_GPIO_UNUSED, // input only @@ -157,7 +159,7 @@ void audioFft(const float* windowed, size_t n, float* outMag) { namespace mm::platform { -bool audioMicInit(AudioMicHandle&, uint16_t, uint16_t, uint16_t, uint32_t) { +bool audioMicInit(AudioMicHandle&, uint16_t, uint16_t, uint16_t, int16_t, uint32_t) { return false; } size_t audioMicRead(AudioMicHandle&, int32_t*, size_t) { return 0; } diff --git a/src/platform/platform.h b/src/platform/platform.h index e9793df9..ab7a4a26 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -164,36 +164,17 @@ int wifiTxPower(); // Call after esp_wifi_start() — earlier calls are silently ignored by ESP-IDF. bool wifiSetTxPower(int8_t quarterDbm); +// mDNS is advertise-only: `mdnsInit` brings the stack up and advertises this device as +// `_http._tcp` (with an `mm=1` TXT) and `_wled._tcp` (with a `mac=` TXT), so the native +// WLED app + Home Assistant discover it. Peer discovery is UDP presence (DevicesModule + +// WledPacket) — the platform exposes advertise here, discovery lives in the module. bool mdnsInit(const char* deviceName); -// Stop advertising (clears the hostname) but keep the mDNS stack up so browse -// queries still work. mdnsShutdown() does the full mdns_free — call at teardown. +// Stop advertising: remove both services and clear the hostname, keeping the stack up so +// a later mdnsInit re-advertises without a full re-init (the mDNS toggle uses this). void mdnsStop(); +// Full mdns_free — call at teardown. void mdnsShutdown(); -// mDNS service browse (discovery) — the standard, push-style way to find devices that -// advertise a service (projectMM, WLED `_wled._tcp`, Home Assistant, ESPHome, …), -// without an HTTP subnet sweep. ONE synchronous call per invocation — it queries, invokes -// `cb` for each found host, frees everything, and returns. It blocks up to `timeoutMs`, so -// the caller runs it on a slow cadence (DevicesModule on loop1s, one service type per tick -// with a small timeout — NOT the render hot path), the standard mDNS-query pattern. -// Deliberately not the async poll-a-handle API: holding the IDF search handle across ticks -// raced the mDNS task's own expiry timer (it freed the search's queue mid-poll → a -// null-queue assert that crashed on a UI refresh); a self-contained call holds no handle, -// so that window can't exist. A found host is reported as a small POD — no IDF types leak -// across the seam. Desktop: stub (no mDNS). -struct MdnsHost { - uint8_t ip[4] = {}; // resolved IPv4 (0.0.0.0 if unresolved) - char hostname[32] = {}; // instance/host name (e.g. "wled-desk"), "" if none - uint16_t port = 0; // advertised SRV port - bool isProjectMM = false; // the host's _http._tcp advertisement carries our TXT - // marker (`mm=1`) — proves it's a projectMM device, not - // just some web box on the same `_http._tcp` service. - // Lets a browse classify a peer without an HTTP probe. -}; -using MdnsHostCb = void(*)(const MdnsHost& host, void* user); -bool mdnsBrowse(const char* service, const char* proto, uint32_t timeoutMs, - MdnsHostCb cb, void* user); - // Store the DHCP hostname (DHCP option 12) the next eth/wifi bring-up advertises. // Routers populate their client list from the DHCP request, not mDNS, so without // this a provisioned device shows as "Unknown" there. Call before ethInit() / @@ -223,20 +204,6 @@ bool http_fetch_to_ota(const char* url, char* statusBuf, size_t statusBufLen, uint32_t* bytesReadOut, uint32_t* bytesTotalOut); -// Short-timeout outbound HTTP GET for device discovery: fetch `url` into the -// caller-owned `body` buffer (NUL-terminated, truncated to bodyLen-1), with a -// per-request timeout. Returns the HTTP status code (e.g. 200), 0 on connect -// timeout / no response / error. DevicesModule uses this to probe each host on -// the LAN and identify it from the response (projectMM /api/state, WLED -// /json/info, else generic). Synchronous and blocking up to `timeoutMs`. The scan -// calls this from loop1s() (the render task), so it bounds the cost to ONE probe per -// tick with a short timeout (~150 ms) and runs the sweep boot-once + on manual request -// only — never continuously — so a blocked probe can't accumulate into LED flicker. -// Moving the probe to its own task (the enabler for fast + periodic sweeping) is in the -// backlog. Desktop: real implementation (libcurl-free, plain socket) so the scan works -// on a PC instance too. -int httpGet(const char* url, uint32_t timeoutMs, char* body, size_t bodyLen); - // Improv WiFi provisioning over UART0. // ESP32 only; desktop stub returns false. Spawns a FreeRTOS task that installs // a UART driver on UART_NUM_0 (the same channel ESP-IDF logging writes to; @@ -553,14 +520,36 @@ RmtLoopbackResult parlioWs2812Loopback(const uint16_t* dataPins, uint8_t laneCou // All inert on targets without I2S, guarded by `if constexpr (platform::hasI2sMic)`. // --------------------------------------------------------------------------- +// `CodecType` + `AudioCodecPins` are defined in platform_config.h (included above), +// alongside the per-target `audioCodecType`/`audioCodecPins` defaults that use them. +// +// Configure the audio codec (record/mic path) over I2C, if the board has one. +// Some boards put an analog mic behind an I2S audio codec (configured over I2C) +// rather than a direct digital I2S MEMS mic; the codec must be brought up before +// the I2S read. This is a no-op returning true on a board with a direct mic +// (CodecType::None) or a target without a codec, so AudioModule always calls it and +// the path stays uniform. +// Returns true when there's nothing to do (CodecType::None / no codec on this +// target) or the codec came up; false on an I2C/codec error (the module then +// idles with a status error, same as a failed mic init). AudioModule calls this +// *after* audioMicInit: the I2S channel must already be driving MCLK before the +// codec is configured (the ES8311 won't answer I2C without MCLK running). The +// codec then presents standard I2S the read picks up. +bool audioCodecInit(CodecType type, const AudioCodecPins& pins, uint32_t sampleRate); + +void audioCodecDeinit(); + // Opaque handle to one configured I2S RX channel (standard/Philips mode). struct AudioMicHandle { void* impl = nullptr; }; // Bring up an I2S RX channel reading the mic on the given pins at `sampleRate` -// (24-bit data in a 32-bit slot, mono). Returns false on failure (bad pins, -// no I2S, out of memory) — the module then idles with a status error. +// (24-bit data in a 32-bit slot, mono). `mclkPin` drives the I2S master clock — +// −1 for a self-clocked direct MEMS mic (INMP441), or the codec's MCLK pin when a +// codec needs the clock to run (the ES8311 won't even answer I2C without it, so +// AudioModule starts I2S *before* audioCodecInit on a codec board). Returns false +// on failure (bad pins, no I2S, out of memory) — the module idles with a status error. bool audioMicInit(AudioMicHandle& h, uint16_t wsPin, uint16_t sdPin, - uint16_t sckPin, uint32_t sampleRate); + uint16_t sckPin, int16_t mclkPin, uint32_t sampleRate); // Read up to `maxSamples` 32-bit samples into `out`; returns the count read // (0 if none ready / not initialised). Non-blocking enough for the render tick. @@ -574,4 +563,25 @@ void audioMicDeinit(AudioMicHandle& h); // desktop — correct, only fast enough for the host tests' small n. void audioFft(const float* windowed, size_t n, float* outMag); +// --------------------------------------------------------------------------- +// I2C bus diagnostics — domain-neutral, not audio-specific. Probes a bus and +// reports which 7-bit addresses ACK, the standard `i2cdetect` operation. Used +// by the I2cScanModule diagnostic (src/core/I2cScanModule.h) to help bring up +// any I2C peripheral (a codec, a sensor, an expander) — confirm wiring and read +// off a device's address. Self-contained: opens a temporary master bus on the +// given pins, scans, tears it down. The bus is transient, so it only conflicts +// with a driver that *currently* holds the port (e.g. the ES8311 codec keeps +// I2C_NUM_0 open while AudioModule is active) — that case is reported as +// kI2cBusUnavailable, not silently as "0 devices". Internal pull-ups enabled. +// --------------------------------------------------------------------------- + +// Sentinel: the bus couldn't be opened (already held by another driver, or no +// I2C on this target) — distinct from a successful scan that found 0 devices. +inline constexpr size_t kI2cBusUnavailable = static_cast(-1); + +// Scan the I2C bus on (sda, scl); write the 7-bit addresses that ACK into +// `out` (caller-sized, capacity `maxOut`) and return the count found (capped at +// maxOut), or kI2cBusUnavailable if the bus couldn't be opened. +size_t i2cScan(uint16_t sda, uint16_t scl, uint8_t* out, size_t maxOut); + } // namespace mm::platform diff --git a/src/ui/app.js b/src/ui/app.js index 84b9deb4..c3938422 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -100,6 +100,11 @@ function connectWs() { } try { const data = JSON.parse(e.data); + // The same /ws also carries WLED-compatibility {state,info} frames for the + // native WLED app (see HttpServerModule's WLED shim). Those are not our + // module-state shape — ignore anything without a `modules` array, or it would + // clobber `state` and blank the module view until the next module frame. + if (!data || !Array.isArray(data.modules)) return; state = data; updateValues(); } catch { @@ -1386,6 +1391,17 @@ function fillListDetail(panel, detail) { vEl.textContent = relativeAge(Number(v)); const ageClass = ageBucketClass(Number(v)); // tint to match the summary dot if (ageClass) vEl.classList.add(ageClass); + } else if (typeof v === "string" && /^https?:\/\//.test(v)) { + // A value that is an http(s) URL (e.g. a device's `url`) renders as a link that + // opens in a new tab — generic, any ListSource detail can surface one. rel + // guards the opened page from reaching back via window.opener. + const a = document.createElement("a"); + a.href = v; + a.textContent = v; + a.target = "_blank"; + a.rel = "noopener noreferrer"; + a.className = "list-detail-link"; + vEl.appendChild(a); } else { vEl.textContent = String(v); } diff --git a/src/ui/style.css b/src/ui/style.css index 6fb82d24..d764ad99 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -619,6 +619,9 @@ body { .list-detail-chip { padding: 0 6px; border-radius: 8px; background: color-mix(in srgb, var(--border) 45%, transparent); font-size: 11px; line-height: 16px; } /* Muted detail value (e.g. a cached device's "last seen: cached" — not a live sighting). */ .list-detail-muted { color: var(--fg-muted); font-style: italic; } +/* A URL detail value (e.g. a device's `url`) rendered as a link opening in a new tab. */ +.list-detail-link { color: var(--accent); text-decoration: underline; } +.list-detail-link:hover { text-decoration: none; } .control-row input[type="range"] { flex: 1; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 14460e6d..f869a009 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -9,6 +9,8 @@ add_executable(mm_tests unit/core/unit_Control_list.cpp unit/core/unit_DeviceIdentify.cpp unit/core/unit_DevicesModule_ageout.cpp + unit/core/unit_DevicesModule_discovery.cpp + unit/core/unit_WledPacket.cpp unit/core/unit_FilesystemModule_persistence.cpp unit/core/unit_FirmwareUpdateModule.cpp unit/core/unit_HttpServerModule_apply.cpp @@ -46,6 +48,7 @@ add_executable(mm_tests unit/light/unit_DistortionWavesEffect.cpp unit/light/unit_Correction.cpp unit/light/unit_Drivers_container.cpp + unit/light/unit_Drivers_firstOutputRgb.cpp unit/light/unit_FireEffect.cpp unit/light/unit_GameOfLifeEffect.cpp unit/light/unit_GridLayout.cpp diff --git a/test/scenarios/light/scenario_modifier_chain.json b/test/scenarios/light/scenario_modifier_chain.json index f230ab5f..f1074c4c 100644 --- a/test/scenarios/light/scenario_modifier_chain.json +++ b/test/scenarios/light/scenario_modifier_chain.json @@ -186,7 +186,7 @@ "observed": { "pc-macos": { "tick_us": [ - 33, + 32, 185 ], "free_heap": [ @@ -199,7 +199,7 @@ ], "at": [ "2026-06-26", - "2026-06-28" + "2026-06-29" ] } } diff --git a/test/unit/core/unit_DeviceIdentify.cpp b/test/unit/core/unit_DeviceIdentify.cpp index b568bd49..9d29c108 100644 --- a/test/unit/core/unit_DeviceIdentify.cpp +++ b/test/unit/core/unit_DeviceIdentify.cpp @@ -1,91 +1,88 @@ -// @module DevicesModule +// @module DevicePlugin +// @also DevicesModule -// Pins the pure device-identification logic DevicesModule uses to classify a -// discovered host and read its name from the HTTP probe body. Extracted into -// DeviceIdentify.h precisely so it's testable without network I/O. These tests -// guard the two bug-prone parts found on the bench: -// - classify: a projectMM /api/state ("modules"), a WLED /json/info ("WLED"), -// anything else generic — and a TRUNCATED projectMM body (deviceName past the -// buffer) must still classify as projectMM off the early "modules" marker. -// - extractDeviceName: projectMM's deviceName is a control OBJECT, so the parser -// must read the control's "value" ("Bench P4"), NOT the first quoted token -// after "deviceName" (which is the "text" TYPE field — the original bug). -// - any garbage / hostile body yields generic + empty name (robustness). +// Pins the device-interop plugin classification: each plugin claims the UDP presence port +// and turns a received datagram into a Device kind. Pure host logic — feed a synthetic +// presence packet (the 44-byte WLED-compatible header), assert the classification, no +// network. The plugins are the "second caller" that makes the seam testable. #include "doctest.h" -#include "core/DeviceIdentify.h" +#include "core/DevicePlugin.h" +#include "core/WledPacket.h" +#include #include -using mm::DevType; -using mm::classifyDevice; -using mm::extractDeviceName; -using mm::devTypeStr; +using namespace mm; -TEST_CASE("classifyDevice: projectMM from /api/state modules array") { - const char* state = "{\"modules\":[{\"name\":\"System\"}]}"; - CHECK(classifyDevice(state, nullptr) == DevType::ProjectMM); -} +namespace { +const uint8_t kSrcIp[4] = {192, 168, 1, 50}; -TEST_CASE("classifyDevice: WLED from /json/info brand") { - const char* info = "{\"ver\":\"0.14\",\"brand\":\"WLED\",\"name\":\"WLED-Desk\"}"; - CHECK(classifyDevice(nullptr, info) == DevType::Wled); +// Build a WLED-valid presence packet; `mm` stamps the projectMM marker (a projectMM peer). +void packet(uint8_t out[WledPacket::kSize], const char* name, bool mm) { + WledPacket::build(out, kSrcIp, name, /*boardType=*/34, /*lightsOn=*/true); + if (mm) WledPacket::stampMmMarker(out); } +} // namespace -TEST_CASE("classifyDevice: a live non-projectMM/non-WLED host is generic") { - CHECK(classifyDevice("router", nullptr) == DevType::Generic); - CHECK(classifyDevice(nullptr, "{\"some\":\"json\"}") == DevType::Generic); - CHECK(classifyDevice(nullptr, nullptr) == DevType::Generic); -} +TEST_CASE("MmPlugin claims a presence packet carrying the projectMM marker") { + MmPlugin p; + CHECK(p.discoveryPort() == WledPacket::kPort); -TEST_CASE("classifyDevice: a truncated projectMM body still classifies (modules is early)") { - // The probe buffer can cut off before deviceName on a big device, but "modules" - // is the very first key — classification must not depend on the full body. - const char* truncated = "{\"modules\":[{\"name\":\"System\",\"type\":\"SystemModule\",\"contro"; - CHECK(classifyDevice(truncated, nullptr) == DevType::ProjectMM); + uint8_t pkt[WledPacket::kSize]; + packet(pkt, "Bench-P4", /*mm=*/true); + DiscoveredDevice d; + REQUIRE(p.classifyPacket(pkt, sizeof(pkt), kSrcIp, d)); + CHECK(d.type == DevType::ProjectMM); + CHECK(std::strcmp(d.name, "Bench-P4") == 0); } -TEST_CASE("extractDeviceName: projectMM reads the deviceName control's value, not the type") { - // The regression: deviceName is {"name":"deviceName","type":"text","value":"X"}. - // A naive "first string after deviceName" grabs "text"; we must get "Bench P4". - const char* state = - "{\"modules\":[{\"name\":\"System\",\"controls\":[" - "{\"name\":\"deviceName\",\"type\":\"text\",\"value\":\"Bench P4\"}," - "{\"name\":\"uptime\",\"type\":\"display\",\"value\":\"0:01\"}]}]}"; - char out[24] = {}; - extractDeviceName(DevType::ProjectMM, state, out, sizeof(out)); - CHECK(std::strcmp(out, "Bench P4") == 0); +TEST_CASE("MmPlugin declines a plain WLED packet (no projectMM marker)") { + MmPlugin p; + uint8_t pkt[WledPacket::kSize]; + packet(pkt, "wled-desk", /*mm=*/false); + DiscoveredDevice d; + CHECK_FALSE(p.classifyPacket(pkt, sizeof(pkt), kSrcIp, d)); } -TEST_CASE("extractDeviceName: WLED reads the top-level name field") { - const char* info = "{\"brand\":\"WLED\",\"name\":\"WLED-Desk\",\"ver\":\"0.14\"}"; - char out[24] = {}; - extractDeviceName(DevType::Wled, info, out, sizeof(out)); - CHECK(std::strcmp(out, "WLED-Desk") == 0); +TEST_CASE("WledPlugin claims a plain WLED packet as WLED") { + WledPlugin p; + CHECK(p.discoveryPort() == WledPacket::kPort); + + uint8_t pkt[WledPacket::kSize]; + packet(pkt, "wled-desk", /*mm=*/false); + DiscoveredDevice d; + REQUIRE(p.classifyPacket(pkt, sizeof(pkt), kSrcIp, d)); + CHECK(d.type == DevType::Wled); + CHECK(std::strcmp(d.name, "wled-desk") == 0); } -TEST_CASE("extractDeviceName: generic / garbage / null bodies yield empty") { - char out[24] = {"sentinel"}; - extractDeviceName(DevType::Generic, "{\"modules\":[]}", out, sizeof(out)); - CHECK(out[0] == 0); // generic has no name source - extractDeviceName(DevType::ProjectMM, "garbage{{{", out, sizeof(out)); - CHECK(out[0] == 0); // no deviceName → empty - extractDeviceName(DevType::ProjectMM, nullptr, out, sizeof(out)); - CHECK(out[0] == 0); // null body → empty, no crash +TEST_CASE("WledPlugin declines a projectMM-marked packet (that's a peer, not a WLED)") { + // A projectMM peer broadcasts a WLED-VALID packet, so without the marker check WledPlugin + // would mis-claim it. The marker keeps the projectMM/WLED kinds distinct. + WledPlugin p; + uint8_t pkt[WledPacket::kSize]; + packet(pkt, "Bench-P4", /*mm=*/true); + DiscoveredDevice d; + CHECK_FALSE(p.classifyPacket(pkt, sizeof(pkt), kSrcIp, d)); } -TEST_CASE("extractDeviceName: respects the output buffer size (no overflow)") { - const char* state = - "{\"modules\":[{\"controls\":[{\"name\":\"deviceName\",\"type\":\"text\"," - "\"value\":\"A-very-long-device-name-well-past-the-buffer\"}]}]}"; - char out[8] = {}; - extractDeviceName(DevType::ProjectMM, state, out, sizeof(out)); - CHECK(std::strlen(out) <= 7); // truncated, NUL-terminated - CHECK(std::strncmp(out, "A-very-", 7) == 0); +TEST_CASE("Plugins decline a short / garbage datagram, never read out of bounds") { + MmPlugin mmp; + WledPlugin wp; + DiscoveredDevice d; + const uint8_t garbage[8] = {1, 2, 3, 4, 5, 6, 7, 8}; // too short, wrong magic + CHECK_FALSE(mmp.classifyPacket(garbage, sizeof(garbage), kSrcIp, d)); + CHECK_FALSE(wp.classifyPacket(garbage, sizeof(garbage), kSrcIp, d)); + CHECK_FALSE(wp.classifyPacket(nullptr, 0, kSrcIp, d)); } -TEST_CASE("devTypeStr maps every type") { - CHECK(std::strcmp(devTypeStr(DevType::ProjectMM), "projectMM") == 0); - CHECK(std::strcmp(devTypeStr(DevType::Wled), "WLED") == 0); - CHECK(std::strcmp(devTypeStr(DevType::Generic), "generic") == 0); +TEST_CASE("WledPlugin tolerates an empty name (the module supplies the IP fallback)") { + WledPlugin p; + uint8_t pkt[WledPacket::kSize]; + packet(pkt, "", /*mm=*/false); + DiscoveredDevice d; + REQUIRE(p.classifyPacket(pkt, sizeof(pkt), kSrcIp, d)); + CHECK(d.type == DevType::Wled); + CHECK(d.name[0] == '\0'); } diff --git a/test/unit/core/unit_DevicesModule_ageout.cpp b/test/unit/core/unit_DevicesModule_ageout.cpp index 0b4927f8..fa8d22af 100644 --- a/test/unit/core/unit_DevicesModule_ageout.cpp +++ b/test/unit/core/unit_DevicesModule_ageout.cpp @@ -1,20 +1,20 @@ // @module DevicesModule -// Pins the timestamp-based age-out: a discovered device that stops being seen by any -// strategy is dropped after kStaleMs, while a still-fresh device and the self entry -// stay. Discovery now arrives on several cadences (HTTP sweep, mDNS lap, future UDP -// beacon), so freshness is a per-device lastSeenMs timestamp and the drop is an -// "unseen too long" check — independent of any one strategy's cycle. Virtual time -// (platform::setTestNowMs) drives it deterministically, no network or wall clock. +// Pins the timestamp-based age-out: a device whose UDP presence packets stop arriving is +// dropped after its window (a cached/restored row has a short probation; a live-confirmed +// one gets the full kStaleMs), while a still-fresh device and the self row stay. Each +// presence packet stamps lastSeenMs, so the drop is an "unheard too long" check. Virtual +// time (platform::setTestNowMs) drives it deterministically, no network or wall clock. // // The module's age-out runs in loop1s(); the test restores a cached list (the public // persistence entry point), advances virtual time, and ticks loop1s() to observe which -// rows survive via listRowCount(). The HTTP/mDNS probes loop1s() also kicks are inert -// here (no network, no advertised services on the host), so the only state change the -// test exercises is age-out. +// rows survive via listRowCount(). The UDP listener loop1s() drains is inert here (no live +// packets on the host bind), and the self row is registered against the host's own IP — so +// the state the test exercises is the age-out path. #include "doctest.h" #include "core/DevicesModule.h" +#include "core/WledPacket.h" #include "core/JsonSink.h" #include "platform/platform.h" @@ -41,11 +41,10 @@ bool present(const DevicesModule& dev, const char* ip) { return false; } -// Restore the two cached devices at t0, advance to t0+dt, tick once. Returns whether -// device A (192.168.1.20) is still present. loop1s() also kicks the boot sweep and adds -// the self row — that's live behaviour and doesn't affect A's age-out timestamp, which -// is what these cases pin. -bool aPresentAfter(uint32_t t0, uint32_t dt) { +// Restore two CACHED devices at t0; optionally re-confirm A with a live packet (so it's +// promoted off `cached`); advance to t0+dt; tick once. Returns whether A (192.168.1.20) is +// still present. A cached device is on a short probation; a live-confirmed one gets 24 h. +bool aPresentAfter(uint32_t t0, uint32_t dt, bool reconfirmA) { ClockGuard guard; // real clock restored on return, even if a REQUIRE below fails platform::setTestNowMs(t0); DevicesModule dev; @@ -53,10 +52,17 @@ bool aPresentAfter(uint32_t t0, uint32_t dt) { // under the control's key — restoreList navigates `member(root, "devices")`. const char* cached = "{\"devices\":[" - "{\"name\":\"A\",\"ip\":\"192.168.1.20\",\"type\":\"generic\"}," + "{\"name\":\"A\",\"ip\":\"192.168.1.20\",\"type\":\"WLED\"}," "{\"name\":\"B\",\"ip\":\"192.168.1.21\",\"type\":\"WLED\"}]}"; REQUIRE(dev.restoreList(cached, "devices")); REQUIRE(dev.listRowCount() == 2); + if (reconfirmA) { + // A live WLED presence packet from A's IP — clears `cached`, gives it the 24 h window. + const uint8_t ip[4] = {192, 168, 1, 20}; + uint8_t pkt[WledPacket::kSize]; + WledPacket::build(pkt, ip, "A", /*boardType=*/34, /*lightsOn=*/true); + dev.injectPacketForTest(pkt, sizeof(pkt), ip); + } platform::setTestNowMs(t0 + dt); dev.loop1s(); // runs ageOut() against the advanced clock return present(dev, "192.168.1.20"); // ClockGuard restores the real clock on return @@ -64,12 +70,50 @@ bool aPresentAfter(uint32_t t0, uint32_t dt) { } // namespace -TEST_CASE("DevicesModule: a still-fresh device survives just under kStaleMs (24h)") { - CHECK(aPresentAfter(1000, 23u * 60u * 60u * 1000u) == true); // 23h < 24h window +// A cached (restored-but-never-re-heard) device is on a short probation, NOT the full 24 h +// — else a long-gone persisted device would survive forever across reboots (its clock +// resets to "boot" each restore). It drops once past kCachedGraceMs. +TEST_CASE("DevicesModule: a cached device survives just under the probation window") { + CHECK(aPresentAfter(1000, 50u * 1000u, /*reconfirmA=*/false) == true); // 50s < 60s probation +} + +TEST_CASE("DevicesModule: a cached device drops once past the probation window") { + CHECK(aPresentAfter(1000, 70u * 1000u, /*reconfirmA=*/false) == false); // 70s > 60s probation +} + +// A live-confirmed device (a presence packet cleared its `cached` flag) gets the full 24 h. +TEST_CASE("DevicesModule: a live-confirmed device survives well past probation (to 24h)") { + CHECK(aPresentAfter(1000, 23u * 60u * 60u * 1000u, /*reconfirmA=*/true) == true); // 23h < 24h +} + +TEST_CASE("DevicesModule: a live-confirmed device drops once past kStaleMs (24h)") { + CHECK(aPresentAfter(1000, 25u * 60u * 60u * 1000u, /*reconfirmA=*/true) == false); // 25h > 24h } -TEST_CASE("DevicesModule: a device drops once past kStaleMs (24h)") { - CHECK(aPresentAfter(1000, 25u * 60u * 60u * 1000u) == false); // 25h > 24h window +// A projectMM peer also answers as a plain WLED (its presence packet without our marker), +// so a later WLED-classified sighting must NOT relabel a restored projectMM row. This +// drives the downgrade-prevention in upsertDevice through the public path: restore the row +// as projectMM, inject a plain WLED packet from the same IP, confirm it stays projectMM. +TEST_CASE("DevicesModule: a restored projectMM device is not downgraded by a WLED packet") { + ClockGuard guard; + platform::setTestNowMs(1); + DevicesModule dev; + const char* cached = + "{\"devices\":[{\"name\":\"MM-Bench\",\"ip\":\"192.168.1.30\",\"type\":\"projectMM\"}]}"; + REQUIRE(dev.restoreList(cached, "devices")); + REQUIRE(dev.listRowCount() == 1); + + // A later plain WLED presence packet (no projectMM marker) from the SAME address. + const uint8_t ip[4] = {192, 168, 1, 30}; + uint8_t pkt[WledPacket::kSize]; + WledPacket::build(pkt, ip, "MM-Bench", /*boardType=*/34, /*lightsOn=*/true); // unmarked = WLED + dev.injectPacketForTest(pkt, sizeof(pkt), ip); + + // Still projectMM — upsertDevice only RAISES toward projectMM, never downgrades. + mm::JsonSink sink; + dev.writeListRow(sink, 0); + CHECK(std::strstr(sink.data(), "\"type\":\"projectMM\"") != nullptr); + CHECK(std::strstr(sink.data(), "\"type\":\"WLED\"") == nullptr); } TEST_CASE("DevicesModule: restore tolerates an empty / malformed cache") { diff --git a/test/unit/core/unit_DevicesModule_discovery.cpp b/test/unit/core/unit_DevicesModule_discovery.cpp new file mode 100644 index 00000000..3a9a7ee5 --- /dev/null +++ b/test/unit/core/unit_DevicesModule_discovery.cpp @@ -0,0 +1,107 @@ +// @module DevicesModule +// @also DevicePlugin + +// Drives the full UDP discovery pipeline on the host — feed synthetic presence packets +// through injectPacketForTest() (the same entry the live recvFrom loop uses) and assert +// the resulting device list: classification, projectMM vs WLED typing, that one device's +// packet never contaminates another's name/type, and live rename. Pure host logic, no +// network — the test seam makes the private upsert path testable. + +#include "doctest.h" +#include "core/DevicesModule.h" +#include "core/WledPacket.h" +#include "core/JsonSink.h" + +#include +#include +#include + +using namespace mm; + +namespace { + +// Inject a presence packet from `a.b.c.d` with `name`; `mm` marks it a projectMM peer. +void inject(DevicesModule& dev, const char* name, bool mm, + uint8_t a, uint8_t b, uint8_t c, uint8_t d) { + const uint8_t ip[4] = {a, b, c, d}; + uint8_t pkt[WledPacket::kSize]; + WledPacket::build(pkt, ip, name, /*boardType=*/34, /*lightsOn=*/true); + if (mm) WledPacket::stampMmMarker(pkt); + dev.injectPacketForTest(pkt, sizeof(pkt), ip); +} + +// Find the serialized row for an IP; return its full JSON (or "" if absent). +std::string rowFor(DevicesModule& dev, const char* ip) { + for (uint8_t i = 0; i < dev.listRowCount(); i++) { + mm::JsonSink sink; + dev.writeListRow(sink, i); + if (std::strstr(sink.data(), ip)) return sink.data(); + } + return ""; +} + +} // namespace + +TEST_CASE("DevicesModule: a plain WLED packet lists a WLED device with its name") { + DevicesModule dev; + inject(dev, "wled-desk", /*mm=*/false, 192, 168, 1, 50); + std::string row = rowFor(dev, "192.168.1.50"); + CHECK(std::strstr(row.c_str(), "\"type\":\"WLED\"") != nullptr); + CHECK(std::strstr(row.c_str(), "wled-desk") != nullptr); +} + +TEST_CASE("DevicesModule: a projectMM-marked packet lists a projectMM device") { + DevicesModule dev; + inject(dev, "MM-Bench", /*mm=*/true, 192, 168, 1, 60); + std::string row = rowFor(dev, "192.168.1.60"); + CHECK(std::strstr(row.c_str(), "\"type\":\"projectMM\"") != nullptr); + CHECK(std::strstr(row.c_str(), "MM-Bench") != nullptr); +} + +TEST_CASE("DevicesModule: a short / garbage datagram is ignored, never listed") { + DevicesModule dev; + const uint8_t ip[4] = {192, 168, 1, 70}; + const uint8_t garbage[8] = {1, 2, 3, 4, 5, 6, 7, 8}; + dev.injectPacketForTest(garbage, sizeof(garbage), ip); + CHECK(dev.listRowCount() == 0); +} + +// The P4-bench bug: two DIFFERENT devices (a WLED and a projectMM peer) must each keep +// their OWN name + type — no cross-contamination between packets. +TEST_CASE("DevicesModule: distinct devices don't cross-contaminate name or type") { + DevicesModule dev; + inject(dev, "wled-desk", /*mm=*/false, 192, 168, 1, 186); // a WLED + inject(dev, "MM-S3", /*mm=*/true, 192, 168, 1, 157); // a projectMM peer + + std::string wled = rowFor(dev, "192.168.1.186"); + std::string mm = rowFor(dev, "192.168.1.157"); + CHECK(std::strstr(wled.c_str(), "wled-desk") != nullptr); + CHECK(std::strstr(wled.c_str(), "\"type\":\"WLED\"") != nullptr); + CHECK(std::strstr(wled.c_str(), "MM-S3") == nullptr); // the contamination bug + CHECK(std::strstr(mm.c_str(), "MM-S3") != nullptr); + CHECK(std::strstr(mm.c_str(), "\"type\":\"projectMM\"") != nullptr); +} + +// A peer RENAME must propagate: a later packet from the same IP with a new name updates +// the row in place — the live-update requirement (the name rides the presence packet). +TEST_CASE("DevicesModule: a peer rename updates the existing row's name") { + DevicesModule dev; + inject(dev, "MM-OldName", /*mm=*/true, 192, 168, 1, 100); + REQUIRE(std::strstr(rowFor(dev, "192.168.1.100").c_str(), "MM-OldName") != nullptr); + inject(dev, "MM-NewName", /*mm=*/true, 192, 168, 1, 100); // same device, new name + std::string row = rowFor(dev, "192.168.1.100"); + CHECK(std::strstr(row.c_str(), "MM-NewName") != nullptr); + CHECK(std::strstr(row.c_str(), "MM-OldName") == nullptr); +} + +// A projectMM device stays projectMM even when a later plain-WLED packet arrives from the +// same address — the type only RAISES toward projectMM, never downgrades. (A projectMM peer +// could be seen via an unmarked packet too; that must not relabel it WLED.) +TEST_CASE("DevicesModule: a projectMM device is not downgraded by a later WLED packet") { + DevicesModule dev; + inject(dev, "MM-Peer", /*mm=*/true, 192, 168, 1, 90); // first: a projectMM-marked packet + inject(dev, "MM-Peer", /*mm=*/false, 192, 168, 1, 90); // later: a plain WLED packet, same IP + std::string row = rowFor(dev, "192.168.1.90"); + CHECK(std::strstr(row.c_str(), "\"type\":\"projectMM\"") != nullptr); + CHECK(std::strstr(row.c_str(), "\"type\":\"WLED\"") == nullptr); +} diff --git a/test/unit/core/unit_WledPacket.cpp b/test/unit/core/unit_WledPacket.cpp new file mode 100644 index 00000000..98232268 --- /dev/null +++ b/test/unit/core/unit_WledPacket.cpp @@ -0,0 +1,68 @@ +// @module WledPacket + +// Pins the WLED presence packet wire format — the 44-byte header projectMM and WLED both +// broadcast on UDP 65506. A wire format breaks silently, so build → parse is pinned here +// directly (the discovery tests exercise it indirectly; this is the focused contract). + +#include "doctest.h" +#include "core/WledPacket.h" + +#include +#include + +using namespace mm; + +TEST_CASE("WledPacket::build produces a valid WLED header (token/id/size)") { + const uint8_t ip[4] = {192, 168, 1, 42}; + uint8_t pkt[WledPacket::kSize]; + WledPacket::build(pkt, ip, "MM-Bench", /*boardType=*/34, /*lightsOn=*/true); + + CHECK(WledPacket::isValid(pkt, sizeof(pkt))); + CHECK(pkt[0] == 255); // token + CHECK(pkt[1] == 1); // id + CHECK(pkt[2] == 192); // ip0 — WLED's subnet check keys on this + CHECK(pkt[5] == 42); + CHECK((pkt[WledPacket::kTypeOff] & 0x7f) == 34); // board type, low 7 bits + CHECK((pkt[WledPacket::kTypeOff] & 0x80) != 0); // lights-on bit +} + +TEST_CASE("WledPacket::readName round-trips the device name") { + const uint8_t ip[4] = {10, 0, 0, 1}; + uint8_t pkt[WledPacket::kSize]; + WledPacket::build(pkt, ip, "WLEDMM-LowlandsLine", /*boardType=*/34, /*lightsOn=*/false); + + char name[24]; + WledPacket::readName(pkt, name, sizeof(name)); + CHECK(std::strcmp(name, "WLEDMM-LowlandsLine") == 0); +} + +TEST_CASE("WledPacket marker is set only when stamped, and stays WLED-valid") { + const uint8_t ip[4] = {10, 0, 0, 2}; + uint8_t plain[WledPacket::kSize]; + WledPacket::build(plain, ip, "wled-x", 32, false); + CHECK_FALSE(WledPacket::hasMmMarker(plain, sizeof(plain))); // a plain WLED packet + + uint8_t marked[WledPacket::kSize]; + WledPacket::build(marked, ip, "MM-x", 34, true); + WledPacket::stampMmMarker(marked); + CHECK(WledPacket::hasMmMarker(marked, sizeof(marked))); // ours + CHECK(WledPacket::isValid(marked, sizeof(marked))); // still a valid WLED header +} + +TEST_CASE("WledPacket::isValid rejects short / wrong-magic / null input") { + const uint8_t shortPkt[10] = {255, 1, 0, 0, 0, 0, 0, 0, 0, 0}; + CHECK_FALSE(WledPacket::isValid(shortPkt, sizeof(shortPkt))); // too short + uint8_t wrongMagic[WledPacket::kSize] = {}; + wrongMagic[0] = 1; wrongMagic[1] = 1; // token != 255 + CHECK_FALSE(WledPacket::isValid(wrongMagic, sizeof(wrongMagic))); + CHECK_FALSE(WledPacket::isValid(nullptr, 0)); +} + +TEST_CASE("WledPacket::readName truncates a long name to the buffer, never overruns") { + const uint8_t ip[4] = {10, 0, 0, 3}; + uint8_t pkt[WledPacket::kSize]; + WledPacket::build(pkt, ip, "a-very-long-device-name-exceeding-field", 34, true); // > 24 + char small[8]; + WledPacket::readName(pkt, small, sizeof(small)); + CHECK(std::strlen(small) == 7); // truncated to cap-1, NUL-terminated +} diff --git a/test/unit/light/unit_Drivers_firstOutputRgb.cpp b/test/unit/light/unit_Drivers_firstOutputRgb.cpp new file mode 100644 index 00000000..308a6426 --- /dev/null +++ b/test/unit/light/unit_Drivers_firstOutputRgb.cpp @@ -0,0 +1,78 @@ +// @module Drivers + +// Pins Drivers::firstOutputRgb — the domain-neutral seam the WLED-compatibility shim uses to +// tint the app's device card with the live first-LED colour. It reads pixel 0 of whichever +// buffer Drivers is driving (the single-layer fast path here: the layer's own buffer). + +#include "doctest.h" +#include "light/drivers/Drivers.h" +#include "light/layers/Layer.h" +#include "light/layouts/Layouts.h" +#include "light/layouts/GridLayout.h" + +#include + +namespace { + +// A 4×4 grid, one layer, pinned directly to a Drivers (the test-rig path). Returns the +// layer so the caller can write pixel 0. +struct Rig { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + mm::Drivers drivers; + + Rig() { + grid.width = 4; grid.height = 4; grid.depth = 1; + layouts.addChild(&grid); + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + layouts.onBuildState(); + layer.onBuildState(); + drivers.setLayer(&layer); // pin: Drivers reads this layer's buffer + } +}; + +} // namespace + +TEST_CASE("Drivers::firstOutputRgb reads pixel 0 of the driven buffer") { + Rig r; + uint8_t* buf = r.layer.buffer().data(); + REQUIRE(buf != nullptr); + buf[0] = 10; buf[1] = 200; buf[2] = 60; // pixel 0 = R,G,B (logical channel order) + + uint8_t rgb[3] = {0, 0, 0}; + REQUIRE(r.drivers.firstOutputRgb(rgb)); + CHECK(rgb[0] == 10); + CHECK(rgb[1] == 200); + CHECK(rgb[2] == 60); +} + +TEST_CASE("Drivers::firstOutputRgb reports black pixel 0 as-is (caller substitutes the default)") { + Rig r; + uint8_t* buf = r.layer.buffer().data(); + REQUIRE(buf != nullptr); + buf[0] = 0; buf[1] = 0; buf[2] = 0; // black — firstOutputRgb returns true with 0,0,0 + + uint8_t rgb[3] = {1, 2, 3}; + REQUIRE(r.drivers.firstOutputRgb(rgb)); // true: there IS an output, it's just black + CHECK(rgb[0] == 0); + CHECK(rgb[1] == 0); + CHECK(rgb[2] == 0); + // (The WLED shim is what maps an all-black read to projectMM purple — pinned in the + // HTTP shim's own logic, not here; this seam just reports the raw pixel.) +} + +TEST_CASE("Drivers::firstOutputRgb returns false when there is no driven buffer") { + mm::Drivers drivers; // no layer pinned, no buffer + uint8_t rgb[3] = {9, 9, 9}; + CHECK_FALSE(drivers.firstOutputRgb(rgb)); +} + +TEST_CASE("MoonModule::firstOutputRgb defaults to false (no output module)") { + // A plain module that doesn't drive output never claims a first LED — the seam's + // safe default, so the shim falls back to purple for a device with no Drivers. + mm::MoonModule m; + uint8_t rgb[3] = {}; + CHECK_FALSE(m.firstOutputRgb(rgb)); +} diff --git a/test/unit/light/unit_RmtLedDriver_pins.cpp b/test/unit/light/unit_RmtLedDriver_pins.cpp index 5167d019..730fd047 100644 --- a/test/unit/light/unit_RmtLedDriver_pins.cpp +++ b/test/unit/light/unit_RmtLedDriver_pins.cpp @@ -234,6 +234,71 @@ TEST_CASE("RmtLedDriver re-slices when the source buffer changes") { CHECK(d.pinLightCount(1) == 100); } +// --- source-buffer window (start / count) ------------------------------------ +// +// Every driver reads the SAME shared buffer and outputs a contiguous slice +// [start, start+count) of it (count 0 = to the end). Two drivers on disjoint +// windows is how the onboard status LED (window [0,1)) coexists with the main +// strip (window [1, N)) without either stealing the other's lights. The slice +// arithmetic lives on DriverBase (windowSlice/setWindow), pinned here through +// RmtLedDriver because it is the host-runnable concrete driver. + +TEST_CASE("RmtLedDriver window: ledsPerPin distributes over the window, not the whole buffer") { + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.pins, "18,17"); + d.setWindow(/*start=*/10, /*count=*/40); // this driver owns lights [10,50) + wire(d, src, corr, 100); // 100-light shared buffer + + REQUIRE(d.pinCount() == 2); + // The even split is over the 40-light WINDOW, not the 100-light buffer. + CHECK(d.pinLightCount(0) == 20); + CHECK(d.pinLightCount(1) == 20); + CHECK(d.windowStart() == 10); +} + +TEST_CASE("RmtLedDriver window: count 0 means the rest of the buffer from start") { + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.pins, "18"); + d.setWindow(/*start=*/1, /*count=*/0); // from light 1 to the end + wire(d, src, corr, 65); // onboard LED took light 0; 64 remain + + REQUIRE(d.pinCount() == 1); + CHECK(d.pinLightCount(0) == 64); // 65 - 1 = 64 +} + +TEST_CASE("RmtLedDriver window: a size-1 window at 0 is the onboard-LED case") { + // The pairing that drove this feature: one driver renders ONLY light 0 (an + // onboard status LED), a second renders the strip from light 1 on. Here we + // pin the first half — exactly one light, regardless of buffer size. + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.pins, "48"); + d.setWindow(/*start=*/0, /*count=*/1); + wire(d, src, corr, 64); + + REQUIRE(d.pinCount() == 1); + CHECK(d.pinLightCount(0) == 1); // just the onboard LED +} + +TEST_CASE("RmtLedDriver window: a start past the buffer end yields an empty slice") { + // Robust to any input: a window beyond the current buffer (e.g. the grid + // shrank) must clamp to zero lights, not read out of bounds. + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strcpy(d.pins, "18"); + d.setWindow(/*start=*/200, /*count=*/10); + wire(d, src, corr, 64); + + CHECK(d.pinLightCount(0) == 0); // nothing to drive; loop() is a no-op + d.loop(); // must not crash / overrun +} + // --- loop() robustness ------------------------------------------------------- // // loop()'s transmit-all/wait-all concurrency body is gated out on the desktop